Files
bdot89andClaude Opus 5.5 262333a852 2.23.0: weapon glows are shared, setup pop-up at login
- other DMorph users (2.23+) see your weapon glows: a g token in look
  messages and G messages; receivers put glows on with DmorphHelper and
  keep them on, skipping glows their game doesn't have
- a pop-up a few seconds after login or /reload when VanillaHelpers isn't
  running or DmorphHelper is missing or older (Don't remind me until the
  next helper version)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:20:18 +01:00

4187 lines
134 KiB
Lua

-- DMorph for OctoWoW: morph state, wardrobe, catalogue search and the chat
-- commands. The window lives in UI.lua and calls the same DmorphOcto.*
-- functions as the commands.
--
-- DMorph itself is a DLL built for WotLK 3.3.5a (build 12340) with that
-- client's addresses hardcoded, so it can't run on OctoWoW's 1.12.1 client.
-- This addon drives the morph API of VanillaHelpers.dll (v1.1.0+) instead:
-- OctoLauncher ships it as the "vanillaHelpers" mod and it is built for the
-- 1.12.1 client.
--
-- The client runs Lua 5.0: no #, no %, no string.match, no select(), and
-- table.getn() counts element by element, so long lists keep their own count.
local VERSION = "2.23.0"
local PREFIX = "|cff33ff99DMorph|r: "
-- VANILLAHELPERS_VERSION is major * 10000 + minor * 100 + patch; morphing
-- came in v1.1.0. Older versions load and set it, but can't morph.
local MORPH_VERSION = 10100
local function VersionText(v)
return format("%d.%d.%d", math.floor(v / 10000), math.floor(math.mod(v, 10000) / 100), math.mod(v, 100))
end
-- The other client mods that load the same way as VanillaHelpers. If none
-- of them runs either, the game wasn't started through OctoLauncher.
local function OtherClientMods()
local mods = {}
if SUPERWOW_VERSION then
table.insert(mods, "SuperWoW")
end
if type(C_Item) == "table" then
table.insert(mods, "ClassicAPI")
end
if type(GetNampowerVersion) == "function" then
table.insert(mods, "nampower")
end
if type(UnitXP) == "function" then
table.insert(mods, "UnitXP")
end
return mods
end
-- Why morphing doesn't work, and how to fix it (one chat-sized message).
local function MissingAPI()
if type(VANILLAHELPERS_VERSION) == "number" and VANILLAHELPERS_VERSION < MORPH_VERSION then
return "VanillaHelpers v" .. VersionText(VANILLAHELPERS_VERSION) .. " is running, but morphing needs v1.1.0 or newer. "
.. "Update the 'vanillaHelpers' mod in OctoLauncher, close the game and start it again."
elseif type(VANILLAHELPERS_VERSION) == "number" then
return "VanillaHelpers is running but its morph functions are missing: reinstall the 'vanillaHelpers' mod in OctoLauncher "
.. "and restart the game."
elseif table.getn(OtherClientMods()) > 0 then
return "VanillaHelpers isn't running, so morphing is unavailable (your other client mods are, so only it is missing). "
.. "Tick the 'vanillaHelpers' mod in OctoLauncher, close the game completely and press Play again."
end
return "VanillaHelpers isn't running, so morphing is unavailable. No client mods are running at all: start the game "
.. "from OctoLauncher (not WoW.exe), with the 'vanillaHelpers' mod ticked. Close the game completely first."
end
-- Player model display IDs, indexed by race ID. VanillaHelpers recognises
-- exactly these and rewrites race and gender as well, so your own skin, face
-- and hair carry over. Race 10 is the High Elf on Turtle-based clients.
local RACES = {
{ name = "Human", male = 49, female = 50, faction = "Alliance" },
{ name = "Orc", male = 51, female = 52, faction = "Horde" },
{ name = "Dwarf", male = 53, female = 54, faction = "Alliance" },
{ name = "Night Elf", male = 55, female = 56, faction = "Alliance" },
{ name = "Undead", male = 57, female = 58, faction = "Horde" },
{ name = "Tauren", male = 59, female = 60, faction = "Horde" },
{ name = "Gnome", male = 1563, female = 1564, faction = "Alliance" },
{ name = "Troll", male = 1478, female = 1479, faction = "Horde" },
{ name = "Goblin", male = 6894, female = 6895, faction = "Horde" },
{ name = "High Elf", male = 15476, female = 15475, faction = "Alliance" },
}
local RACE_ALIASES = {
human = 1, orc = 2, dwarf = 3, nightelf = 4, ne = 4, undead = 5, scourge = 5,
forsaken = 5, tauren = 6, gnome = 7, troll = 8, goblin = 9, highelf = 10,
bloodelf = 10,
}
-- UnitRace() file tokens, only used when the native model isn't in RACES.
local RACE_TOKENS = {
HUMAN = 1, ORC = 2, DWARF = 3, NIGHTELF = 4, SCOURGE = 5, TAUREN = 6,
GNOME = 7, TROLL = 8, GOBLIN = 9, HIGHELF = 10, BLOODELF = 10,
}
-- Inventory slots that show on the character model, numbered like DMorph
-- and GetInventorySlotInfo(). Neck, rings and trinkets have no model.
local SLOT_ORDER = { 1, 3, 4, 5, 6, 7, 8, 9, 10, 15, 16, 17, 18, 19 }
local SLOT_NAMES = {
[1] = "Head", [3] = "Shoulder", [4] = "Shirt", [5] = "Chest", [6] = "Waist",
[7] = "Legs", [8] = "Feet", [9] = "Wrist", [10] = "Hands", [15] = "Back",
[16] = "Main hand", [17] = "Off hand", [18] = "Ranged", [19] = "Tabard",
}
-- The slots whose enchant glow DmorphHelper can change.
local GLOW_SLOT = { [16] = true, [17] = true, [18] = true }
local SLOT_ALIASES = {
head = 1, shoulder = 3, shoulders = 3, shirt = 4, chest = 5, waist = 6,
belt = 6, legs = 7, feet = 8, boots = 8, wrist = 9, wrists = 9, hands = 10,
gloves = 10, back = 15, cloak = 15, mainhand = 16, offhand = 17,
ranged = 18, tabard = 19,
}
DmorphOcto = {
VERSION = VERSION,
MissingAPI = MissingAPI,
RACES = RACES,
SLOT_ORDER = SLOT_ORDER,
SLOT_NAMES = SLOT_NAMES,
GLOW_SLOTS = { 16, 17, 18 },
}
local M = DmorphOcto
-- An outfit is { display, race, gender (0 male, 1 female), items[slot] }.
local db -- DmorphOctoDB, per character: the outfit currently applied
local account -- DmorphOctoAccount, shared: saved outfits and the minimap button
local nativeRace, nativeGender
local sessionReady = false
local warnedMissingAPI = false
local pendingApply
local listeners = {}
local creatures
local items = {}
local sets
local looks = {}
local InitDB -- defined under State; the catalogue needs it first
local function Print(msg)
DEFAULT_CHAT_FRAME:AddMessage(PREFIX .. msg)
end
local function Report(ok, message)
Print(message)
end
local function HasMorphAPI()
return type(SetUnitDisplayID) == "function"
and type(SetUnitVisibleItemID) == "function"
and type(UnitDisplayInfo) == "function"
end
M.HasMorphAPI = HasMorphAPI
-- VanillaHelpers reports bad input with lua_error, so every call is guarded.
-- A nil value clears that override.
local function Morph(fn, a1, a2, a3)
return pcall(fn, a1, a2, a3)
end
local function ParseID(text)
local n = tonumber(text or "")
if n and n >= 0 and n <= 2147483647 and n == math.floor(n) then
return n
end
end
M.ParseID = ParseID
local function Trim(text)
local _, _, trimmed = strfind(text or "", "^%s*(.-)%s*$")
return trimmed
end
local function RaceModel(race, gender)
if gender == 1 then
return RACES[race].female
end
return RACES[race].male
end
function M.DescribeBody(race, gender)
if gender == 1 then
return RACES[race].name .. " female"
end
return RACES[race].name .. " male"
end
local function RaceList()
local list = ""
for race = 1, table.getn(RACES) do
list = list .. ", " .. race .. " " .. RACES[race].name
end
return strsub(list, 3)
end
local function SlotList()
local list = ""
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
list = list .. ", " .. slot .. " " .. strlower(SLOT_NAMES[slot])
end
return strsub(list, 3)
end
-- Catalogue -----------------------------------------------------------------
-- Data/*.lua hold the catalogue as text so the game loads it quickly; each
-- list is parsed the first time it's needed. An entry is
-- { id, name, lowercase name, quality, kind }.
local function NewCatalog()
return { ids = {}, names = {}, lower = {}, quality = {}, kind = {}, byID = {}, count = 0 }
end
local function AddEntry(catalog, id, name, quality, kind)
local n = catalog.count + 1
catalog.count = n
catalog.ids[n], catalog.names[n], catalog.lower[n] = id, name, strlower(name)
catalog.quality[n], catalog.kind[n] = quality, kind
if not catalog.byID[id] then
catalog.byID[id] = n
end
end
local function Before(a, b)
return a[3] < b[3] or (a[3] == b[3] and a[1] < b[1])
end
-- Merges two name-sorted lists. The data files are sorted the way Lua
-- compares strings, so this is one pass.
local function Merge(a, n, b, m)
local out, k, i, j = {}, 0, 1, 1
while i <= n or j <= m do
local x, y = a[i], b[j]
if not y or (x and not Before(y, x)) then
i = i + 1
else
x, j = y, j + 1
end
k = k + 1
out[k] = x
end
return out, k
end
local function Fill(catalog, list, count)
for i = 1, count do
local e = list[i]
AddEntry(catalog, e[1], e[2], e[4], e[5])
end
return catalog
end
-- Creature models by kind: a creature's name, or named after the model file;
-- 2 and 3 are the same for the models OctoWoW added to the stock 1.12.1 client.
M.KIND_CREATURE, M.KIND_MODEL, M.KIND_NEW, M.KIND_NEW_MODEL = 0, 1, 2, 3
-- Models brought over from later expansions by the porting tool, listed in
-- Data/Ported.lua (only on a PC that has the tool's patch-Z.mpq).
M.KIND_PORTED = 4
M.MODEL_FILTERS = {
{ text = "All models" },
{ text = "Creatures", kinds = { [0] = true, [2] = true } },
{ text = "Bosses", tag = "boss" },
{ text = "Rare creatures", tag = "rare" },
{ text = "Mounts", tag = "mount" },
{ text = "Other models", kinds = { [1] = true, [3] = true } },
{ text = "New in OctoWoW", kinds = { [2] = true, [3] = true } },
{ text = "Ported (later expansions)", kinds = { [4] = true } },
}
-- Items and sets are kind 0 (stock 1.12.1) or 2 (added by Turtle WoW / OctoWoW).
M.ITEM_FILTERS = {
{ text = "All items" },
{ text = "1.12 items", kinds = { [0] = true } },
{ text = "New in OctoWoW", kinds = { [2] = true } },
{ text = "Ported (later expansions)", kinds = { [4] = true } },
}
M.SET_FILTERS = {
{ text = "All sets" },
{ text = "1.12 sets", kinds = { [0] = true } },
{ text = "New in OctoWoW", kinds = { [2] = true } },
{ text = "Ported (later expansions)", kinds = { [4] = true } },
}
-- Item and set quality, as GetItemInfo() numbers it.
-- Item kinds (class * 100 + subclass, Data/ItemTypes.lua), in the order the
-- filter lists them: armour by material, then weapons.
M.ITEM_TYPES = {
{ 401, "Cloth" }, { 402, "Leather" }, { 403, "Mail" }, { 404, "Plate" }, { 406, "Shield" },
{ 400, "Miscellaneous" }, { 407, "Libram" }, { 408, "Idol" }, { 409, "Totem" },
{ 215, "Dagger" }, { 213, "Fist weapon" }, { 200, "One-handed axe" }, { 204, "One-handed mace" },
{ 207, "One-handed sword" }, { 201, "Two-handed axe" }, { 205, "Two-handed mace" },
{ 208, "Two-handed sword" }, { 206, "Polearm" }, { 210, "Staff" }, { 217, "Spear" }, { 202, "Bow" },
{ 218, "Crossbow" }, { 203, "Gun" }, { 216, "Thrown" }, { 219, "Wand" }, { 214, "Miscellaneous weapon" },
{ 220, "Fishing pole" },
}
M.ITEM_TYPE_NAMES = {}
for i = 1, table.getn(M.ITEM_TYPES) do
M.ITEM_TYPE_NAMES[M.ITEM_TYPES[i][1]] = M.ITEM_TYPES[i][2]
end
-- GetItemInfo's English sub-types, for items no data file knows.
local SUBTYPES = {
Armor = { Miscellaneous = 400, Cloth = 401, Leather = 402, Mail = 403, Plate = 404, Shields = 406,
Librams = 407, Idols = 408, Totems = 409 },
Weapon = { ["One-Handed Axes"] = 200, ["Two-Handed Axes"] = 201, Bows = 202, Guns = 203,
["One-Handed Maces"] = 204, ["Two-Handed Maces"] = 205, Polearms = 206, ["One-Handed Swords"] = 207,
["Two-Handed Swords"] = 208, Staves = 210, ["Fist Weapons"] = 213, Miscellaneous = 214, Daggers = 215,
Thrown = 216, Spears = 217, Crossbows = 218, Wands = 219, ["Fishing Pole"] = 220 },
}
M.QUALITY_FILTERS = {
{ text = "Any quality" },
{ text = "Poor", quality = 0 },
{ text = "Common", quality = 1 },
{ text = "Uncommon", quality = 2 },
{ text = "Rare", quality = 3 },
{ text = "Epic", quality = 4 },
{ text = "Legendary", quality = 5 },
}
-- Display and item IDs that can't show in this client: Data/HiddenModels.lua
-- and Data/HiddenItems.lua, written by tools/build_models.ps1 and
-- tools/build_items.ps1 from the game's own files.
local hiddenModels, hiddenItems
local function ReadIDs(text)
local set = {}
for id in string.gfind(text or "", "%d+") do
set[tonumber(id)] = true
end
return set
end
local function HiddenModels()
if not hiddenModels then
hiddenModels = ReadIDs(DmorphOcto_HiddenModels)
DmorphOcto_HiddenModels = nil
end
return hiddenModels
end
local function HiddenItems()
if not hiddenItems then
hiddenItems = ReadIDs(DmorphOcto_HiddenItems)
DmorphOcto_HiddenItems = nil
end
return hiddenItems
end
-- Display IDs from Data/Ported.lua: only PCs with the same ported pack have
-- their models. Others see your own model instead (Share.lua sends ported
-- looks apart, tagged with the pack, to DMorph users who have the same one).
local portedIDs = {}
-- Which ported pack this PC has: the porting tool's DmorphOcto_PortedPack if
-- it wrote one, else how many models, items and sets Ported.lua's three lists
-- have (read here, as Ported.lua loads before this file and the lists are
-- dropped once parsed). Two PCs with the same pack (Pack Ported.bat /
-- Install Ported.bat) match. Rows, not the lists' sizes (before 2.17.1): a
-- fix to the names or tags (mount names, rider poses) changed the size and
-- stopped two PCs with the same models seeing each other's ported looks.
local function Rows(text)
local _, n = string.gsub(text or "", "\n", "\n")
return n
end
local portedPack
if type(DmorphOcto_PortedPack) == "string" then
portedPack = DmorphOcto_PortedPack
elseif DmorphOcto_PortedData or DmorphOcto_PortedItems then
portedPack = "P" .. Rows(DmorphOcto_PortedData) .. "x" .. Rows(DmorphOcto_PortedItems)
.. "x" .. Rows(DmorphOcto_PortedSets)
end
if portedPack and not strfind(portedPack, "^%w+$") then
portedPack = nil
end
-- Rider poses: ported mounts whose riders Blizzard poses another way (Ported.lua's
-- tag "rider<animation>": 120 stands crouched on the mage discs, boards, kites
-- and carpets, 97 sits). DmorphHelper v14 (DmorphHelper_SetRiderPose) puts them
-- on whoever rides one. Read now, before the list is parsed and dropped.
local riderPoses = {}
for id, anim in string.gfind(DmorphOcto_PortedData or "", "(%d+)\t[^\t\r\n]*\t[^\t\r\n]*rider(%d+)") do
riderPoses[tonumber(id)] = tonumber(anim)
end
function M.GetRiderPose(displayID)
return riderPoses[tonumber(displayID or 0)]
end
local posesRegistered
local function RegisterRiderPoses()
if posesRegistered or type(DmorphHelper_SetRiderPose) ~= "function" or not next(riderPoses) then
return
end
posesRegistered = true
for id, anim in pairs(riderPoses) do
-- An error: the helper couldn't change the game's riding pose (/dmorph check says so).
if not pcall(DmorphHelper_SetRiderPose, id, anim) then
return
end
end
end
function M.GetPortedPack()
return portedPack
end
function M.IsPorted(displayID)
M.GetCreatureCatalog()
return portedIDs[tonumber(displayID or 0)] == true
end
-- Creatures.lua (named creatures) and Models.lua (every other display ID the
-- client has) together list every model, less the ones that can't show.
function M.GetCreatureCatalog()
if creatures then
return creatures
end
local hidden = HiddenModels()
local named, other, n, m = {}, {}, 0, 0
for id, name in string.gfind(DmorphOcto_CreatureData or "", "(%d+)\t([^\r\n]+)") do
id = tonumber(id)
if not hidden[id] then
n = n + 1
named[n] = { id, name, strlower(name), 0, M.KIND_CREATURE }
end
end
for id, kind, name in string.gfind(DmorphOcto_ModelData or "", "(%d+)\t(%d)\t([^\r\n]+)") do
id = tonumber(id)
if not hidden[id] then
m = m + 1
other[m] = { id, name, strlower(name), 0, tonumber(kind) }
end
end
DmorphOcto_CreatureData, DmorphOcto_ModelData = nil, nil -- the parsed tables replace the text
local all, count = Merge(named, n, other, m)
-- Data/Ported.lua: displayID<TAB>name<TAB>tags ("mount").
local ported, p, portedMounts = {}, 0, {}
for id, name, tags in string.gfind(DmorphOcto_PortedData or "", "(%d+)\t([^\t\r\n]+)\t?([^\r\n]*)") do
id = tonumber(id)
p = p + 1
ported[p] = { id, name, strlower(name), 0, M.KIND_PORTED }
portedIDs[id] = true
if strfind(tags, "mount") then
portedMounts[id] = true
end
end
DmorphOcto_PortedData = nil
table.sort(ported, Before)
creatures = Fill(NewCatalog(), Merge(all, count, ported, p))
-- Tags for the filters: bosses and rares by display and name, mounts by
-- display. Data/ModelTags.lua (OctoWoW's client and Turtle WoW's ranks) and
-- Data/ClassicTags.lua (the 1.12.1 database); a boss stays a boss. A mount
-- with its own name (Blizzard's) is listed under that name only, not under
-- every creature that shares the display.
local byName, mounts, mountTitles, titled = {}, {}, {}, {}
for _, text in ipairs({ DmorphOcto_TagData or "", DmorphOcto_ClassicTagData or "" }) do
for id, tag, name in string.gfind(text, "(%d+)\t(%a+)\t([^\r\n]*)") do
if tag == "mount" then
mounts[tonumber(id)] = true
if name ~= "" then
mountTitles[id .. "\t" .. strlower(name)] = true
end
else
local key = id .. "\t" .. strlower(name)
if byName[key] ~= "boss" then
byName[key] = tag
end
end
end
end
DmorphOcto_TagData, DmorphOcto_ClassicTagData = nil, nil
for id in pairs(portedMounts) do
mounts[id] = true
end
-- Displays with an entry under the mount's own name (else every entry counts).
for i = 1, creatures.count do
if mountTitles[creatures.ids[i] .. "\t" .. creatures.lower[i]] then
titled[creatures.ids[i]] = true
end
end
creatures.tags = {}
for i = 1, creatures.count do
local id = creatures.ids[i]
local key = id .. "\t" .. creatures.lower[i]
local tag = byName[key]
if mounts[id] and (not titled[id] or mountTitles[key]) then
tag = (tag and (tag .. " ") or "") .. "mount"
end
creatures.tags[i] = tag
end
return creatures
end
-- Inventory slots by the INVTYPE_ name GetItemInfo() returns.
local EQUIP_SLOTS = {
INVTYPE_HEAD = { 1 }, INVTYPE_SHOULDER = { 3 }, INVTYPE_BODY = { 4 }, INVTYPE_CHEST = { 5 },
INVTYPE_ROBE = { 5 }, INVTYPE_WAIST = { 6 }, INVTYPE_LEGS = { 7 }, INVTYPE_FEET = { 8 },
INVTYPE_WRIST = { 9 }, INVTYPE_HAND = { 10 }, INVTYPE_CLOAK = { 15 }, INVTYPE_TABARD = { 19 },
INVTYPE_WEAPON = { 16, 17 }, INVTYPE_2HWEAPON = { 16 }, INVTYPE_WEAPONMAINHAND = { 16 },
INVTYPE_SHIELD = { 17 }, INVTYPE_WEAPONOFFHAND = { 17 }, INVTYPE_HOLDABLE = { 17 },
INVTYPE_RANGED = { 18 }, INVTYPE_THROWN = { 18 }, INVTYPE_RANGEDRIGHT = { 18 },
}
-- Developers' test items ("Kazgrim Test Helm", "1000 Test dagger 60 blue"):
-- "test" as a word in the name.
local function IsTestItem(lower)
local at, stop = strfind(lower, "test", 1, true)
while at do
if not strfind(strsub(lower, at - 1, at - 1), "%a") and not strfind(strsub(lower, stop + 1, stop + 1), "%a") then
return true
end
at, stop = strfind(lower, "test", stop + 1, true)
end
return false
end
local function ParseItems(text, kind, list, n, skip)
local hidden = HiddenItems()
for id, quality, name in string.gfind(text or "", "(%d+)\t(%d)\t([^\r\n]+)") do
id = tonumber(id)
if not ((skip and skip[id]) or hidden[id] or IsTestItem(strlower(name))) then
n = n + 1
list[n] = { id, name, strlower(name), tonumber(quality), kind }
end
end
return list, n
end
-- Items the game told us about in this or an earlier session (see Item
-- lookup), saved as DmorphOctoAccount.itemInfo[id] = "slots<TAB>quality<TAB>name".
local function LearnedItems(slot, skip)
local list, n, hidden = {}, 0, HiddenItems()
for id, info in pairs(account.itemInfo) do
if info and not skip[id] and not hidden[id] then
local _, _, slots, quality, name = strfind(info, "^([%d,]+)\t(%-?%d+)\t(.+)$")
if slots and strfind("," .. slots .. ",", "," .. slot .. ",", 1, true) and not IsTestItem(strlower(name)) then
n = n + 1
list[n] = { id, name, strlower(name), tonumber(quality), M.KIND_NEW }
end
end
end
table.sort(list, Before)
return list, n
end
-- itemID -> kind, from Data/ItemTypes.lua and ItemsExtra.lua's item cache
-- kinds, read once.
local itemTypes
local function ReadItemTypes()
itemTypes = {}
for _, text in ipairs({ DmorphOcto_ItemTypeData or "", DmorphOcto_ItemTypeExtra or "" }) do
for kind, list in string.gfind(text, "(%d+)\t([%d,]+)") do
kind = tonumber(kind)
for id in string.gfind(list, "%d+") do
itemTypes[tonumber(id)] = kind
end
end
end
DmorphOcto_ItemTypeData, DmorphOcto_ItemTypeExtra = nil, nil
end
-- Ported weapons (Data/Ported.lua, DmorphOcto_PortedItems): item IDs only this
-- PC has. DmorphHelper v10+ answers the game's item cache for them with a real
-- item of the same kind (the template: held and sheathed the same) showing
-- the ported model. Kind -> the slots it fits (armour lines name their slot).
local PORTED_SLOTS = {
[200] = { 16, 17 }, [204] = { 16, 17 }, [207] = { 16, 17 }, [213] = { 16, 17 }, [215] = { 16, 17 },
[201] = { 16 }, [205] = { 16 }, [206] = { 16 }, [208] = { 16 }, [210] = { 16 },
[202] = { 18 }, [203] = { 18 }, [216] = { 18 }, [218] = { 18 }, [219] = { 18 },
[214] = { 17 }, [406] = { 17 },
}
local portedItems
local function PortedItems()
if not portedItems then
portedItems = {}
-- virtualID<TAB>display<TAB>kind<TAB>template<TAB>name[<TAB>slot[<TAB>quality]]
-- (slot 0 or none: the slots its kind fits; quality none: epic).
for id, display, kind, template, name, slot, quality in string.gfind(DmorphOcto_PortedItems or "",
"(%d+)\t(%d+)\t(%d+)\t(%d+)\t([^\t\r\n]+)\t?(%d*)\t?(%d*)") do
slot = tonumber(slot)
if slot == 0 then
slot = nil
end
portedItems[tonumber(id)] = { display = tonumber(display), kind = tonumber(kind), template = tonumber(template),
name = name, slot = slot, quality = tonumber(quality) or 4 }
end
DmorphOcto_PortedItems = nil
end
return portedItems
end
function M.IsPortedItem(itemID)
return PortedItems()[tonumber(itemID or 0)] ~= nil
end
local function HasVirtualItems()
return type(DmorphHelper_SetVirtualItem) == "function"
end
M.HasVirtualItems = HasVirtualItems
-- Tells DmorphHelper about every ported weapon, and asks the server for the
-- template items the game hasn't seen (their records are copied).
local portedRegistered
local function RegisterPortedItems()
if portedRegistered or not HasVirtualItems() or not next(PortedItems()) then
return
end
portedRegistered = true
local asked = {}
for id, p in pairs(PortedItems()) do
pcall(DmorphHelper_SetVirtualItem, id, p.template, p.display)
if not asked[p.template] then
asked[p.template] = true
local ok, name = pcall(GetItemInfo, p.template)
if not (ok and name) and type(C_Item) == "table" then
pcall(C_Item.RequestLoadItemDataByID, p.template)
end
end
end
end
M.RegisterPortedItems = RegisterPortedItems
-- An item's kind (401 cloth, 215 dagger, ...: M.ITEM_TYPES), or nil. Items no
-- data file knows are asked from the game, which knows the ones it has seen.
function M.GetItemType(itemID)
local ported = PortedItems()[itemID]
if ported then
return ported.kind
end
if not itemTypes then
ReadItemTypes()
end
local kind = itemTypes[itemID]
if not kind then
local ok, _, _, _, _, class, subclass = pcall(GetItemInfo, itemID)
kind = ok and SUBTYPES[class or ""] and SUBTYPES[class][subclass or ""]
if kind then
itemTypes[itemID] = kind
end
end
return kind
end
-- The kinds a catalogue's entries have, in M.ITEM_TYPES' order, each with how
-- many: { { kind, name, count }, ... }.
function M.GetCatalogTypes(catalog)
local counts = {}
for i = 1, catalog.count do
local kind = catalog.types and catalog.types[i]
if kind then
counts[kind] = (counts[kind] or 0) + 1
end
end
local list = {}
for i = 1, table.getn(M.ITEM_TYPES) do
local kind = M.ITEM_TYPES[i][1]
if counts[kind] then
table.insert(list, { kind, M.ITEM_TYPES[i][2], counts[kind] })
end
end
return list
end
-- Every item that fits the slot: Items.lua (the 1.12.1 database), then
-- ItemsExtra.lua (Turtle WoW and OctoWoW) and the items looked up in game.
function M.GetItemCatalog(slot)
if not items[slot] then
InitDB()
local base, n = ParseItems(DmorphOcto_ItemData and DmorphOcto_ItemData[slot], 0, {}, 0)
local seen = {}
for i = 1, n do
seen[base[i][1]] = true
end
local extra, m = ParseItems(DmorphOcto_ItemExtra and DmorphOcto_ItemExtra[slot], M.KIND_NEW, {}, 0, seen)
for i = 1, m do
seen[extra[i][1]] = true
end
local learned, k = LearnedItems(slot, seen)
local both, count = Merge(base, n, extra, m)
local all, total = Merge(both, count, learned, k)
-- Ported weapons and armour that fit the slot, with their Retail quality.
local ported, p = {}, 0
for id, item in pairs(PortedItems()) do
local slots = item.slot and { item.slot } or PORTED_SLOTS[item.kind] or {}
for i = 1, table.getn(slots) do
if slots[i] == slot then
p = p + 1
ported[p] = { id, item.name, strlower(item.name), item.quality, M.KIND_PORTED }
end
end
end
table.sort(ported, Before)
local catalog = Fill(NewCatalog(), Merge(all, total, ported, p))
catalog.types = {}
for i = 1, catalog.count do
catalog.types[i] = M.GetItemType(catalog.ids[i])
end
items[slot] = catalog
end
return items[slot]
end
-- The Slutmog tab's list (user, 18 Oct: "its own tab"): every revealing piece
-- (Data/Slutmog.lua) and OctoWoW's invisible Unseen Fashion items, from the
-- armour slots' item lists; catalog.slots[i] is the slot it's worn in.
-- Rebuilt when looked-up items join the lists.
local slutmogCatalog
M.SLUTMOG_SLOTS = { 1, 3, 4, 5, 6, 7, 8, 9, 10, 15, 19 }
function M.GetSlutmogCatalog()
local version = M.GetItemsVersion()
if not slutmogCatalog or slutmogCatalog.version ~= version then
local list, n, slotOf = {}, 0, {}
for s = 1, table.getn(M.SLUTMOG_SLOTS) do
local slot = M.SLUTMOG_SLOTS[s]
local c = M.GetItemCatalog(slot)
for i = 1, c.count do
local id = c.ids[i]
if not slotOf[id] and M.IsSlutmogItem(id, c.lower[i]) then
n = n + 1
list[n] = { id, c.names[i], c.lower[i], c.quality[i], c.kind[i] }
slotOf[id] = slot
end
end
end
table.sort(list, Before)
slutmogCatalog = Fill(NewCatalog(), list, n)
slutmogCatalog.slots = {}
for i = 1, slutmogCatalog.count do
slutmogCatalog.slots[i] = slotOf[slutmogCatalog.ids[i]]
end
slutmogCatalog.version = version
end
return slutmogCatalog
end
-- The slots an item can be morphed into, or nil if it isn't known.
local itemSlots
function M.GetItemSlots(itemID)
if not itemSlots then
itemSlots = {}
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
for id in pairs(M.GetItemCatalog(slot).byID) do
local list = itemSlots[id]
if not list then
list = {}
itemSlots[id] = list
end
table.insert(list, slot)
end
end
end
return itemSlots[itemID]
end
-- Item lists are rebuilt the next time they're needed (after a lookup).
local function ForgetItemLists()
items, itemSlots = {}, nil
if sets then
sets.qualityKnown = nil
end
end
-- An item's quality from the item lists, or nil.
local function ItemQuality(itemID)
local slots = M.GetItemSlots(itemID)
if slots then
local catalog = M.GetItemCatalog(slots[1])
return catalog.quality[catalog.byID[itemID]]
end
end
-- Every item set in the client, with the pieces that show on a character. A
-- set's quality is that of its best known piece.
function M.GetSetCatalog()
if not sets then
sets = NewCatalog()
sets.pieces = {}
local list, n = {}, 0
local function Pieces(text)
local pieces = {}
for piece in string.gfind(text, "%d+") do
table.insert(pieces, tonumber(piece))
end
return pieces
end
for id, kind, name, pieces in string.gfind(DmorphOcto_SetData or "", "(%d+)\t(%d)\t([^\t\r\n]+)\t([%d,]+)") do
n = n + 1
list[n] = { tonumber(id), name, strlower(name), tonumber(kind), Pieces(pieces) }
end
-- Data/Ported.lua's sets (DmorphOcto_PortedSets: name<TAB>virtual items),
-- only on the PC that has them; numbered after every real set.
local p = 0
for name, pieces in string.gfind(DmorphOcto_PortedSets or "", "([^\t\r\n]+)\t([%d,]+)") do
p = p + 1
n = n + 1
list[n] = { 900000 + p, name, strlower(name), M.KIND_PORTED, Pieces(pieces) }
end
DmorphOcto_SetData, DmorphOcto_PortedSets = nil, nil
if p > 0 then
table.sort(list, Before)
end
for i = 1, n do
AddEntry(sets, list[i][1], list[i][2], nil, list[i][4])
sets.pieces[sets.count] = list[i][5]
end
end
if not sets.qualityKnown then
sets.qualityKnown = true
sets.types = {}
for i = 1, sets.count do
-- Quality: the best piece's. Kind: the armour most pieces are made of.
local best, materials, kind = nil, {}, nil
for k = 1, table.getn(sets.pieces[i]) do
local quality = ItemQuality(sets.pieces[i][k])
if quality and (not best or quality > best) then
best = quality
end
local piece = M.GetItemType(sets.pieces[i][k])
if piece and piece >= 401 and piece <= 404 then
materials[piece] = (materials[piece] or 0) + 1
if not kind or materials[piece] > materials[kind] then
kind = piece
end
end
end
sets.quality[i], sets.types[i] = best, kind
end
end
return sets
end
-- The game's dressed NPCs of a race and gender (Data/Looks.lua), the fewest
-- clothes first. Each look is { displayID, skin, face, hair style, hair
-- colour, facial hair, pieces of clothing, outfit number }.
function M.GetLooks(race, gender)
local key = race .. ":" .. gender
if not looks[key] then
local list, n, hidden = {}, 0, HiddenModels()
local text = DmorphOcto_LookData and DmorphOcto_LookData[key] or ""
for a, b, c, d, e, f, g, h in string.gfind(text, "(%d+),(%d+),(%d+),(%d+),(%d+),(%d+),(%d+),(%d+)") do
if not hidden[tonumber(a)] then
n = n + 1
list[n] = { tonumber(a), tonumber(b), tonumber(c), tonumber(d), tonumber(e), tonumber(f), tonumber(g), tonumber(h) }
end
end
looks[key] = list
end
return looks[key]
end
-- The character creator's rows and the look field each one reads. NPC looks
-- are fixed combinations, so a row's < > goes to the next value some NPC of
-- that race has, in the look that keeps the most of the other rows.
M.LOOK_FEATURES = { "Skin colour", "Face", "Hair style", "Hair colour", "Facial hair", "Outfit" }
local LOOK_FIELDS = { 2, 3, 4, 5, 6, 8 }
-- What keeping each row is worth when another one changes. Each outweighs
-- all the ones below it: skin colour, hair style, hair colour, face, facial
-- hair, outfit.
local FEATURE_WEIGHTS = { 32, 4, 16, 8, 2, 1 }
-- The values row f (1-6) has among a race and gender's looks, sorted.
function M.GetLookValues(race, gender, f)
local list = M.GetLooks(race, gender)
local field = LOOK_FIELDS[f]
local seen, values = {}, {}
for k = 1, table.getn(list) do
local v = list[k][field]
if not seen[v] then
seen[v] = true
table.insert(values, v)
end
end
table.sort(values)
return values
end
-- Row f's value in a look.
function M.LookValue(look, f)
return look[LOOK_FIELDS[f]]
end
-- The position of the look that has `value` in row f and keeps the most of
-- look `from`'s other rows (then the most similar clothes), or nil.
function M.FindLook(race, gender, f, value, from)
local list = M.GetLooks(race, gender)
local field, want = LOOK_FIELDS[f], list[from]
local best, bestScore
for k = 1, table.getn(list) do
local look = list[k]
if look[field] == value then
local keep, pieces = 0, 0
if want then
for g = 1, 6 do
if g ~= f and look[LOOK_FIELDS[g]] == want[LOOK_FIELDS[g]] then
keep = keep + FEATURE_WEIGHTS[g]
end
end
pieces = math.abs(look[7] - want[7])
end
local score = keep * 1000 - pieces * 20 - look[7]
if not best or score > bestScore then
best, bestScore = k, score
end
end
end
return best
end
-- Every value of row f and, for each, the look FindLook picks from look `from`.
function M.GetLookChoices(race, gender, f, from)
local values, at = M.GetLookValues(race, gender, f), {}
for i = 1, table.getn(values) do
at[values[i]] = M.FindLook(race, gender, f, values[i], from)
end
return values, at
end
-- Row f of look `from` one value along (step 1 or -1, wrapping around). Returns
-- the new look's position, its place among the row's values, how many values
-- there are, and the other rows that had to change with it ({} if none); or
-- nil when every NPC of the race has the same value.
function M.StepLook(race, gender, f, from, step)
local list = M.GetLooks(race, gender)
local values = M.GetLookValues(race, gender, f)
local n, cur = table.getn(values), list[from]
if n < 2 or not cur then
return nil
end
local pos = 1
for i = 1, n do
if values[i] == cur[LOOK_FIELDS[f]] then
pos = i
end
end
pos = math.mod(pos - 1 + step + n, n) + 1
local k = M.FindLook(race, gender, f, values[pos], from)
local changed = {}
for g = 1, 6 do
if g ~= f and list[k][LOOK_FIELDS[g]] ~= cur[LOOK_FIELDS[g]] then
table.insert(changed, g)
end
end
return k, pos, n, changed
end
-- Item lookup -------------------------------------------------------------------
-- ItemsExtra.lua lists items whose slot isn't known offline: OctoWoW items
-- from Atlas-CFM's loot tables and the item sets. In the background, a few a
-- second, the addon reads them from the client's cache or (with ClassicAPI)
-- asks the server, and saves the answers account-wide. Found ones join the
-- item lists and the sets.
local LOOKUP_STEP, LOOKUP_TIMEOUT, LOOKUP_PARALLEL = 0.25, 20, 4
local lookup = { pos = 1, count = 0, ids = {}, waiting = {}, waitCount = 0, left = 0, version = 0 }
local function CanAskServer()
return type(C_Item) == "table" and type(C_Item.RequestLoadItemDataByID) == "function"
end
-- "slots<TAB>quality<TAB>name" for an item the client knows, false if it
-- doesn't show on a character, nil if the client doesn't know it yet.
local function ReadItemInfo(itemID)
local ok, name, _, quality, _, _, _, _, equipLoc = pcall(GetItemInfo, itemID)
if not (ok and name) then
return nil
end
local slots = EQUIP_SLOTS[equipLoc or ""]
if not slots then
return false
end
return table.concat(slots, ",") .. "\t" .. (quality or 1) .. "\t" .. string.gsub(name, "\t", " ")
end
local function Record(itemID, info)
if account.itemInfo[itemID] == nil and info ~= nil then
account.itemInfo[itemID] = info
lookup.left = lookup.left - 1
if info then
lookup.changed = true
end
end
end
-- How many items are still to be looked up, and how many there were.
function M.GetLookupStatus()
return lookup.left, lookup.count
end
-- Bumped whenever looked-up items join the lists, so the window can redraw.
function M.GetItemsVersion()
return lookup.version
end
local function StartLookup()
if lookup.started then
return
end
lookup.started = true
for id in string.gfind(DmorphOcto_ItemPending or "", "(%d+)\t") do
id = tonumber(id)
lookup.count = lookup.count + 1
lookup.ids[lookup.count] = id
if account.itemInfo[id] == nil then
lookup.left = lookup.left + 1
end
end
DmorphOcto_ItemPending = nil
-- OctoWoW's "Summer Collection of Unseen Fashion" (invisible pieces; the tabard
-- is 109): asked for once so they join the lists and the Slutmog filter.
for id = 100, 160 do
if account.itemInfo[id] == nil then
lookup.count = lookup.count + 1
lookup.ids[lookup.count] = id
lookup.left = lookup.left + 1
end
end
end
local function LookupTick(elapsed)
for id, wait in pairs(lookup.waiting) do
if wait <= elapsed then
-- No answer: try again next session.
lookup.waiting[id] = nil
lookup.waitCount = lookup.waitCount - 1
else
lookup.waiting[id] = wait - elapsed
end
end
local askServer = CanAskServer()
local checked = 0
while lookup.pos <= lookup.count and checked < 40 and lookup.waitCount < LOOKUP_PARALLEL do
local id = lookup.ids[lookup.pos]
lookup.pos = lookup.pos + 1
if account.itemInfo[id] == nil and not lookup.waiting[id] then
checked = checked + 1
local info = ReadItemInfo(id)
if info ~= nil then
Record(id, info)
elseif askServer then
lookup.waiting[id] = LOOKUP_TIMEOUT
lookup.waitCount = lookup.waitCount + 1
pcall(C_Item.RequestLoadItemDataByID, id)
break -- one question per step
end
end
end
if lookup.changed then
lookup.changed = nil
lookup.version = lookup.version + 1
ForgetItemLists()
end
return lookup.pos > lookup.count and lookup.waitCount == 0
end
-- ClassicAPI's ITEM_DATA_LOAD_RESULT(itemID, success).
local function LookupAnswer(itemID, success)
if not lookup.waiting[itemID] then
return
end
lookup.waiting[itemID] = nil
lookup.waitCount = lookup.waitCount - 1
if success then
Record(itemID, ReadItemInfo(itemID))
else
Record(itemID, false) -- the server has no such item
end
end
-- Slutmog: the revealing pieces Data/Slutmog.lua lists (read once) and any item
-- named like OctoWoW's invisible "Summer Collection of Unseen Fashion".
local slutmog
function M.IsSlutmogItem(itemID, lowerName)
if not slutmog then
slutmog = {}
for id in string.gfind(DmorphOcto_SlutmogData or "", "(%d+)") do
slutmog[tonumber(id)] = true
end
DmorphOcto_SlutmogData = nil
end
return slutmog[itemID] or (lowerName and strfind(lowerName, "unseen fashion", 1, true)) and true or false
end
-- Whether entry i passes a filter from the *_FILTERS lists ({ kinds = ..., tag
-- = ... }) and a quality from QUALITY_FILTERS. Both are optional.
local function Keep(catalog, i, filter, quality, kind)
if kind and not (catalog.types and catalog.types[i] == kind) then
return false
end
if filter then
if filter.kinds and not filter.kinds[catalog.kind[i]] then
return false
end
if filter.tag then
local tags = catalog.tags and catalog.tags[i]
if not (tags and strfind(tags, filter.tag, 1, true)) then
return false
end
end
end
-- Legendary also lists artifacts (quality 6: the Warglaives of Azzinoth).
return not quality or catalog.quality[i] == quality or (quality == 5 and catalog.quality[i] == 6)
end
-- Positions of the catalogue entries matching the text, plus how many there
-- are: an exact ID first, then names starting with the text, then names
-- containing it. byID lists them by ID instead; kind keeps only one item kind.
function M.FilterCatalog(catalog, text, filter, quality, byID, kind)
local query = strlower(Trim(text))
local result, n = {}, 0
local number = ParseID(query)
local later, m = {}, 0
for i = 1, catalog.count do
if Keep(catalog, i, filter, quality, kind) then
local at = query == "" or strfind(catalog.lower[i], query, 1, true)
if query == "" or catalog.ids[i] == number or at == 1 then
n = n + 1
result[n] = i
elseif at then
m = m + 1
later[m] = i
end
end
end
for i = 1, m do
result[n + i] = later[i]
end
n = n + m
if byID then
local ids = catalog.ids
table.sort(result, function(a, b)
return ids[a] < ids[b] or (ids[a] == ids[b] and a < b)
end)
end
return result, n
end
-- The catalogue's name for a display ID and what kind of entry it is, or nil.
function M.GetDisplayName(displayID)
local catalog = M.GetCreatureCatalog()
local index = catalog.byID[displayID]
if index then
return catalog.names[index], catalog.kind[index]
end
end
-- How a preview frames a display so the whole model shows (Data/ModelFrames.lua
-- and, for ported models, Data/PortedFrames.lua (one pack's), both made by
-- tools/build_frames.ps1): the half height the model's portrait camera sees
-- at its target, the target (x, y, z), the middle of the model's vertices
-- (x, y, z), its height and widest side, in model units. nil when the
-- display has no data. Last: whether the model has a full-body ("character
-- info", type 1) camera.
local frameModels, frameDisplays
local function ParseFrames(data, models, displays)
if type(data) ~= "table" or type(data.models) ~= "string" or type(data.displays) ~= "string" then
return
end
local rows = {}
for line in string.gfind(data.models, "[^\n]+") do
local values, n = {}, 0
for v in string.gfind(line, "%S+") do
n = n + 1
values[n] = tonumber(v)
end
if n == 10 then
table.insert(rows, values)
elseif n == 9 then
values[10] = 0
table.insert(rows, values)
elseif n == 7 then
-- 2.21.0's lines: no x / y middle (taken as the model's origin).
table.insert(rows, { values[1], values[2], values[3], values[4], 0, 0, values[5], values[6], values[7], 0 })
else
table.insert(rows, false)
end
end
local base = table.getn(models)
for i = 1, table.getn(rows) do
models[base + i] = rows[i]
end
for id, row in string.gfind(data.displays, "(%d+):(%d+)") do
local values = rows[tonumber(row)]
if values then
displays[tonumber(id)] = base + tonumber(row)
end
end
end
function M.GetModelFrame(displayID)
if not frameModels then
frameModels, frameDisplays = {}, {}
ParseFrames(DmorphOcto_ModelFrames, frameModels, frameDisplays)
-- Ported frames come with DMorph (Data/PortedFrames.lua) for one pack:
-- the display IDs only mean those models with that pack.
local ported = DmorphOcto_PortedFrames
if type(ported) == "table" and (not ported.pack or ported.pack == M.GetPortedPack()) then
ParseFrames(ported, frameModels, frameDisplays)
end
-- The strings aren't needed once parsed.
DmorphOcto_ModelFrames, DmorphOcto_PortedFrames = nil, nil
end
local f = frameDisplays[displayID or 0] and frameModels[frameDisplays[displayID]]
if f then
return f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9], f[10] == 1
end
end
function M.DescribeDisplay(displayID)
local name = M.GetDisplayName(displayID)
if name then
return name .. " (" .. displayID .. ")"
end
return "display " .. displayID
end
-- The item's name from the client's cache, or else from the catalogue: the
-- slot's own list first, then the others (a shirt can be morphed onto the chest).
function M.DescribeItem(itemID, slot)
if itemID == 0 then
return "nothing (your own gear hidden)"
end
-- The game answers a ported item with its template's record, name and all
-- (the Sets tooltip listed "Layered Tunic"): its own name is in Ported.lua.
local ported = PortedItems()[itemID]
if ported then
return "[" .. ported.name .. "] (" .. itemID .. ")"
end
local ok, name = pcall(GetItemInfo, itemID)
if not (ok and name) then
name = nil
local lists = { slot }
for i = 1, table.getn(SLOT_ORDER) do
table.insert(lists, SLOT_ORDER[i])
end
for i = 1, table.getn(lists) do
local catalog = SLOT_NAMES[lists[i]] and M.GetItemCatalog(lists[i])
local index = catalog and catalog.byID[itemID]
if index then
name = catalog.names[index]
break
end
end
end
if name then
return "[" .. name .. "] (" .. itemID .. ")"
end
return "item " .. itemID
end
function M.DescribeOutfit(outfit)
local look = "your own model"
if outfit.display then
look = M.DescribeDisplay(outfit.display)
elseif outfit.race then
look = M.DescribeBody(outfit.race, outfit.gender)
end
local count = 0
for _, id in pairs(outfit.items) do
if id ~= 0 then -- 0: a slot a set hid
count = count + 1
end
end
local extra = ""
if outfit.look then
extra = extra .. ", own face"
end
if outfit.glows and next(outfit.glows) then
extra = extra .. ", glows"
end
if outfit.mount then
extra = extra .. ", mount"
end
if count == 1 then
return look .. ", 1 item" .. extra
end
return look .. ", " .. count .. " items" .. extra
end
-- State ---------------------------------------------------------------------
-- Five look values (skin colour, face, hair style, hair colour, facial hair),
-- copied, or nil if look isn't one.
local function ValidLook(look)
if type(look) ~= "table" then
return nil
end
for i = 1, 5 do
local v = look[i]
if type(v) ~= "number" or v < 0 or v > 255 or v ~= math.floor(v) then
return nil
end
end
return { look[1], look[2], look[3], look[4], look[5] }
end
-- Drops anything that isn't a usable ID, e.g. from a hand-edited file.
local function SanitizeOutfit(outfit)
outfit.display = ParseID(outfit.display)
if outfit.display == 0 then
outfit.display = nil
end
outfit.mount = ParseID(outfit.mount)
if outfit.mount == 0 then
outfit.mount = nil
end
if not (type(outfit.race) == "number" and RACES[outfit.race]) then
outfit.race = nil
end
if outfit.gender ~= 1 then
outfit.gender = 0
end
if type(outfit.items) ~= "table" then
outfit.items = {}
end
for slot, itemID in pairs(outfit.items) do
itemID = ParseID(itemID)
if not SLOT_NAMES[slot] or itemID == 0 then
itemID = nil
end
outfit.items[slot] = itemID
end
-- Weapon glows (DmorphHelper): slot -> enchant ID, 0 for none.
if type(outfit.glows) ~= "table" then
outfit.glows = {}
end
for slot, enchantID in pairs(outfit.glows) do
enchantID = ParseID(enchantID)
if not GLOW_SLOT[slot] or (enchantID and enchantID > 1000000) then
enchantID = nil
end
outfit.glows[slot] = enchantID
end
-- An outfit's own look: { race, gender, the five values }.
local look = outfit.look
if type(look) == "table" and RACES[look.race] and (look.gender == 0 or look.gender == 1) and ValidLook(look) then
outfit.look = ValidLook(look)
outfit.look.race, outfit.look.gender = look.race, look.gender
else
outfit.look = nil
end
return outfit
end
-- The current look as an outfit: db, plus the look picked for your body.
local function CopyOutfit(outfit)
local copy = { display = outfit.display, race = outfit.race, gender = outfit.gender, mount = outfit.mount,
items = {}, glows = {} }
for slot, itemID in pairs(outfit.items) do
copy.items[slot] = itemID
end
for slot, enchantID in pairs(outfit.glows) do
copy.glows[slot] = enchantID
end
local race, gender = M.GetBodyRace()
local look = race and ValidLook(outfit.looks[race .. ":" .. gender])
if look then
look.race, look.gender = race, gender
copy.look = look
end
return copy
end
function InitDB()
if db then
return
end
if type(DmorphOctoDB) ~= "table" then
DmorphOctoDB = {}
end
db = SanitizeOutfit(DmorphOctoDB)
-- Mount skins per mount (own display -> skin) and the mounts ridden.
if type(db.mountSkins) ~= "table" then
db.mountSkins = {}
end
for real, skin in pairs(db.mountSkins) do
if type(real) ~= "number" or not ParseID(skin) or ParseID(skin) == 0 then
db.mountSkins[real] = nil
end
end
local ridden = {}
if type(db.ridden) == "table" then
for i = 1, table.getn(db.ridden) do
local id = ParseID(db.ridden[i])
if id and id > 0 then
table.insert(ridden, id)
end
end
end
db.ridden = ridden
-- DmorphHelper: the look picked per body ("race:gender" -> five values),
-- and per weapon slot and for the look, the real values last seen and
-- what was last written.
local picked = {}
if type(db.looks) == "table" then
for key, look in pairs(db.looks) do
local _, _, race = strfind(type(key) == "string" and key or "", "^(%d+):[01]$")
if race and RACES[tonumber(race)] then
picked[key] = ValidLook(look)
end
end
end
db.looks = picked
db.lookReal, db.lookWritten = ValidLook(db.lookReal), ValidLook(db.lookWritten)
-- Your size (DmorphHelper v2): the picked one, the real one last seen and
-- what was last written, each 0.1-10.
-- The languages your character speaks (CaptureNative): names, or nil.
if type(db.languages) == "table" then
local names = {}
for i = 1, table.getn(db.languages) do
if type(db.languages[i]) == "string" then
table.insert(names, db.languages[i])
end
end
db.languages = table.getn(names) > 0 and names or nil
else
db.languages = nil
end
if type(db.defaultLanguage) ~= "string" then
db.defaultLanguage = nil
end
-- Your own race and gender, last read cleanly (CaptureNative).
if not (type(db.ownRace) == "number" and RACES[db.ownRace]) then
db.ownRace = nil
end
if db.ownGender ~= 0 and db.ownGender ~= 1 then
db.ownGender = nil
end
local time = tonumber(db.time)
db.time = time and time >= 0 and time < 1440 and math.floor(time) or nil
local weatherKind = type(db.weather) == "table" and tonumber(db.weather[1])
local weatherStrength = type(db.weather) == "table" and tonumber(db.weather[2])
if weatherKind and weatherStrength and weatherKind >= 0 and weatherKind <= 3 and weatherStrength >= 0
and weatherStrength <= 1 then
db.weather = { weatherKind, weatherStrength }
else
db.weather = nil
end
for _, key in ipairs({ "scale", "scaleReal", "scaleWritten" }) do
local v = tonumber(db[key])
if not (v and v >= 0.1 and v <= 10) then
v = nil
end
db[key] = v
end
for _, key in ipairs({ "glowReal", "glowWritten" }) do
local kept = {}
if type(db[key]) == "table" then
for slot, pair in pairs(db[key]) do
if GLOW_SLOT[slot] and type(pair) == "table" and type(pair[1]) == "number" and type(pair[2]) == "number" then
kept[slot] = { pair[1], pair[2] }
end
end
end
db[key] = kept
end
if type(DmorphOctoAccount) ~= "table" then
DmorphOctoAccount = {}
end
account = DmorphOctoAccount
if type(account.outfits) ~= "table" then
account.outfits = {}
end
-- Items looked up in game: id -> "slots<TAB>quality<TAB>name", or false.
if type(account.itemInfo) ~= "table" then
account.itemInfo = {}
end
for id, info in pairs(account.itemInfo) do
if type(id) ~= "number" or (info ~= false and type(info) ~= "string") then
account.itemInfo[id] = nil
end
end
for name, outfit in pairs(account.outfits) do
if type(name) == "string" and type(outfit) == "table" then
SanitizeOutfit(outfit)
else
account.outfits[name] = nil
end
end
-- Favourites: { id = displayID, mount = true or nil }, oldest first, each once.
local favourites, seen = {}, {}
if type(account.favourites) == "table" then
for i = 1, table.getn(account.favourites) do
local fav = account.favourites[i]
local id = type(fav) == "table" and ParseID(fav.id)
if id and id > 0 then
local mount = fav.mount and true or nil
local key = (mount and "mount" or "model") .. id
if not seen[key] then
seen[key] = true
table.insert(favourites, { id = id, mount = mount })
end
end
end
end
account.favourites = favourites
-- Sharing with other DMorph users (Share.lua): on unless switched off.
account.shareOff = account.shareOff == true or nil
account.setsKeepOwnGear = account.setsKeepOwnGear == true or nil
account.othersOff = account.othersOff == true or nil
end
function M.GetState()
InitDB()
return db
end
function M.GetAccount()
InitDB()
return account
end
function M.GetNative()
return nativeRace, nativeGender
end
-- Called after every change, so the window can redraw.
function M.RegisterListener(fn)
table.insert(listeners, fn)
end
local function Notify()
for i = 1, table.getn(listeners) do
listeners[i]()
end
end
local function CaptureNative()
nativeRace, nativeGender = nil, nil
local ok, _, native = pcall(UnitDisplayInfo, "player")
if ok and native then
for race = 1, table.getn(RACES) do
if RACES[race].male == native then
nativeRace, nativeGender = race, 0
elseif RACES[race].female == native then
nativeRace, nativeGender = race, 1
end
end
end
if not nativeRace then
local _, token = UnitRace("player")
nativeRace = RACE_TOKENS[strupper(token or "")]
local sex = UnitSex("player")
if sex == 3 then
nativeGender = 1
elseif sex == 2 then
nativeGender = 0
end
end
-- Logging in during a race morph, VanillaHelpers can still show the
-- morph's race and model here (its overrides outlive a logout), and the
-- morph was taken for your own race: NPCs of your faction hostile, no
-- flight paths, until a /reload (user, 13 Oct). So your race is kept per
-- character (db.ownRace) whenever it's read cleanly, and a read that is
-- the saved morph's race, or of the other faction (a race morph doesn't
-- change your faction), uses the kept one.
local morphRace = db.race
if not morphRace and db.display then
for race = 1, table.getn(RACES) do
if RACES[race].male == db.display or RACES[race].female == db.display then
morphRace = race
end
end
end
local faction = UnitFactionGroup("player")
local suspect = nativeRace and morphRace and nativeRace == morphRace
-- Only with a morph on: without one the read is clean, whatever faction
-- a server gives a race.
local wrongFaction = (db.race or db.display) and nativeRace and RACES[nativeRace] and RACES[nativeRace].faction
and faction and RACES[nativeRace].faction ~= faction
if suspect or wrongFaction then
if db.ownRace and RACES[db.ownRace] and (not faction or RACES[db.ownRace].faction == faction) then
nativeRace, nativeGender = db.ownRace, db.ownGender
elseif wrongFaction then
-- Nothing kept yet: at least a race of your faction.
nativeRace = faction == "Horde" and 2 or 1
end
return -- the languages read now would be the morph's
end
if nativeRace then
db.ownRace, db.ownGender = nativeRace, nativeGender
end
-- The game lists the languages of the race it thinks you are, and a race
-- morph changes that (a Gnome morph lists Common and Gnomish), so the
-- ones your character speaks are read now, with your own race showing,
-- and kept for chat (see SpeakableLanguage).
local languages = {}
for i = 1, 20 do
local got, name = pcall(GetLanguageByIndex, i)
if not (got and type(name) == "string") then
break
end
table.insert(languages, name)
end
if table.getn(languages) > 0 then
local got, default = pcall(GetDefaultLanguage)
db.languages = languages
db.defaultLanguage = got and default or nil
end
end
-- Runs once per UI session, before the first morph is applied.
local function EnsureSession()
if sessionReady then
return
end
-- VanillaHelpers' overrides can outlive a /reload, and a race morph also
-- replaces the native model ID, so drop ours before reading the real one.
if db.display or db.race then
Morph(SetUnitDisplayID, "player", nil)
end
-- The mount skin is put back on by the mount watcher once you ride.
if type(SetUnitMountDisplayID) == "function" and (db.mount or next(db.mountSkins)) then
Morph(SetUnitMountDisplayID, "player", nil)
end
CaptureNative()
RegisterPortedItems()
RegisterRiderPoses()
sessionReady = true
end
-- Every action that morphs starts here.
local function Begin()
InitDB()
if not HasMorphAPI() then
return false
end
EnsureSession()
return true
end
-- .morph and the race/gender morph share UNIT_FIELD_DISPLAYID: last one wins.
local function BodyDisplayID()
if db.display then
return db.display
end
if db.race then
return RaceModel(db.race, db.gender)
end
end
-- After a body change the game is still building the model, and a look or
-- glow written into it then could mix with the old one; so DmorphHelper's
-- watcher holds off this long, then runs (see Glows and your own look).
local helperHold, helperWait = 0, 0
-- A race morph makes VanillaHelpers tell the game you are that race (the
-- model's skin needs it), and two windows go by that race: the flight map (a
-- Horde character morphed into a Human sees no Horde flight paths) and the
-- spellbook (spells change places). While one of them is open your own race
-- is back, and the morph returns when it closes. raceHeld: why ("taxi",
-- "spellbook").
local raceHeld = {}
local function DisplayRace(display)
for race = 1, table.getn(RACES) do
if RACES[race].male == display or RACES[race].female == display then
return race
end
end
end
-- A race morph's race stays in the game's race byte (the game textures your
-- model from it, so it has to). DmorphHelper (v6+) puts your own race in only
-- while the game reads an incoming chat message (so it understands your
-- languages) or works out your base reputations (built once from your race:
-- with another faction's race, NPCs of your own faction turn hostile), and
-- the addon does below while the flight map, spellbook or reputation list is
-- open: the byte alone, no rebuild, so your model doesn't change. The helper
-- is told your own race all the time, not only during a race morph, so a
-- loading screen during one can't build your reputations with the wrong
-- race. raceKept: the race it was told (0: none yet).
local raceKept = 0
local raceSwapped = {} -- reasons the byte holds your own race (HoldRace)
local function MorphRace()
local morph = DisplayRace(BodyDisplayID() or 0)
if sessionReady and morph and nativeRace and morph ~= nativeRace then
return morph
end
end
local function KeepRace()
if type(DmorphHelper_SetOwnRace) ~= "function" then
return
end
local want = sessionReady and nativeRace or 0
if want ~= raceKept and pcall(DmorphHelper_SetOwnRace, want) then
raceKept = want
end
end
local function ApplyBody()
helperHold, helperWait = 0.5, 0.5
if next(raceHeld) then
return true -- it goes on when the flight map or spellbook closes
end
local ok, err = Morph(SetUnitDisplayID, "player", BodyDisplayID())
KeepRace()
if next(raceSwapped) and MorphRace() then
pcall(DmorphHelper_SetRace, "player", nativeRace) -- still open
end
return ok, err
end
-- The race byte has to match the model you show: the game paints your skin,
-- hair and gear by that race, and with another race's there you stand there
-- half naked in the wrong skin (user, 18 Oct: after changing race and set;
-- neither a /reload nor Reset everything helped, only restarting the game).
-- VanillaHelpers keeps the byte it found when a race morph went on and puts
-- it back when the morph comes off; if the byte held another race just then,
-- that race stays for good. So once a second the byte is checked against the
-- model the game shows (a racial model's race, else your own) and repaired.
-- Not while a window holds your own race (HoldRace). raceRepairs: how many.
local raceRepairs = 0
local function RepairRace()
if not (sessionReady and nativeRace) or next(raceSwapped) or next(raceHeld)
or type(DmorphHelper_GetRace) ~= "function" or type(DmorphHelper_SetRace) ~= "function" then
return
end
local ok, display = pcall(UnitDisplayInfo, "player")
local got, race = pcall(DmorphHelper_GetRace, "player")
if not (ok and type(display) == "number" and got and type(race) == "number") then
return
end
local want = DisplayRace(display) or nativeRace
if race ~= want and pcall(DmorphHelper_SetRace, "player", want) then
raceRepairs = raceRepairs + 1
if type(DmorphHelper_Rebuild) == "function" then
pcall(DmorphHelper_Rebuild, "player")
end
end
end
M.RepairRace = RepairRace
function M.GetRaceRepairs()
return raceRepairs
end
-- Holds (on) or releases your own race for a reason. Returns whether the
-- race was changed back just now, so the window can redraw.
function M.HoldRace(reason, on)
-- With DmorphHelper: only the race byte, for as long as the window is open.
if type(DmorphHelper_SetRace) == "function" then
if on then
local morph = MorphRace()
if not morph or not pcall(DmorphHelper_SetRace, "player", nativeRace) then
return false
end
local first = not next(raceSwapped)
raceSwapped[reason] = true
return first
end
if raceSwapped[reason] then
raceSwapped[reason] = nil
local morph = MorphRace()
if not next(raceSwapped) and morph then
pcall(DmorphHelper_SetRace, "player", morph)
end
end
return false
end
-- Without it: your own model while the window is open.
if on then
local held = next(raceHeld) ~= nil
if held then
raceHeld[reason] = true
return false
end
local race = DisplayRace(BodyDisplayID() or 0)
if not (sessionReady and race and race ~= nativeRace and HasMorphAPI()) then
return false
end
raceHeld[reason] = true
helperHold = 1000000 -- no picked look on your own race meanwhile
if M.HasHelper() and db.lookWritten and db.lookReal then
local l = db.lookReal
if pcall(DmorphHelper_SetLook, "player", l[1], l[2], l[3], l[4], l[5]) then
db.lookWritten = nil
end
end
Morph(SetUnitDisplayID, "player", nil)
return true
end
if raceHeld[reason] then
raceHeld[reason] = nil
if not next(raceHeld) then
ApplyBody()
end
end
return false
end
-- A template the game knows for a ported item: its own, else any item of the
-- same kind the game has cached. Wearing a ported item whose template the
-- server never sent turned the player white until a relog (guns, thrown).
local knownTemplate = {}
-- Where an armour slot's template may be worn (GetItemInfo's equip location).
-- The template's record says where the game draws the piece: a ported
-- shoulder copying a helmet was drawn on the head (user, 13 Oct: purple
-- pieces hanging off the shoulder with some sets).
local SLOT_EQUIP = {
[1] = { INVTYPE_HEAD = true }, [3] = { INVTYPE_SHOULDER = true }, [4] = { INVTYPE_BODY = true },
[5] = { INVTYPE_CHEST = true, INVTYPE_ROBE = true }, [6] = { INVTYPE_WAIST = true }, [7] = { INVTYPE_LEGS = true },
[8] = { INVTYPE_FEET = true }, [9] = { INVTYPE_WRIST = true }, [10] = { INVTYPE_HAND = true },
[15] = { INVTYPE_CLOAK = true }, [19] = { INVTYPE_TABARD = true },
}
-- Whether the game knows item id and it can stand in for ported item p:
-- armour must be worn in p's slot, weapons only need to be known.
local function TemplateFits(p, id)
local ok, name, _, _, _, _, _, _, equip = pcall(GetItemInfo, id)
if not (ok and name) then
return false
end
return not p.slot or (SLOT_EQUIP[p.slot] and SLOT_EQUIP[p.slot][equip or ""]) or false
end
-- The item whose record ported item p copies: its own template if the game
-- knows it, else a known item worn in the same slot (armour) or of the same
-- kind (weapons), else nil (and the template is asked for).
local function PortedTemplate(p)
if TemplateFits(p, p.template) then
return p.template
end
local key = p.kind
if p.slot then
key = "slot" .. p.slot
end
local cached = knownTemplate[key]
if cached and TemplateFits(p, cached) then
return cached
end
if p.slot then
for id in pairs(M.GetItemCatalog(p.slot).byID) do
if not PortedItems()[id] and TemplateFits(p, id) then
knownTemplate[key] = id
return id
end
end
else
if not itemTypes then
ReadItemTypes()
end
for id, kind in pairs(itemTypes) do
if kind == p.kind and TemplateFits(p, id) then
knownTemplate[key] = id
return id
end
end
end
if type(C_Item) == "table" then
pcall(C_Item.RequestLoadItemDataByID, p.template)
end
return nil
end
-- Whether a ported item can go on another player here: DmorphHelper knows
-- the virtual items and the game knows a template to copy.
function M.CanShowPortedItem(itemID)
local p = PortedItems()[tonumber(itemID or 0)]
if not (p and HasVirtualItems()) then
return false
end
RegisterPortedItems()
local template = PortedTemplate(p)
if not template then
return false
end
if template ~= p.template then
p.template = template
pcall(DmorphHelper_SetVirtualItem, itemID, template, p.display)
end
return true
end
-- The icon of the known item a ported piece copies (the piece's own ID has
-- none until the game has that item: the slot showed a question mark).
function M.GetPortedIcon(itemID)
if not M.CanShowPortedItem(itemID) then
return nil
end
local p = PortedItems()[tonumber(itemID)]
local ok, _, _, _, _, _, _, _, _, texture = pcall(GetItemInfo, p.template)
return ok and texture or nil
end
local function ApplyItem(slot)
local id = db.items[slot]
if id and M.IsPortedItem(id) then
-- A ported armour piece goes in its own slot only. Outfits saved before
-- the pack renumbered its pieces (13 Oct) put a belt on the head, gloves
-- on the shoulders...; the game draws each piece where its record says,
-- and the clash left the character invisible, white in previews (user, 16 Oct).
local piece = PortedItems()[id]
if piece.slot and piece.slot ~= slot then
db.items[slot] = nil
Morph(SetUnitVisibleItemID, "player", slot, nil)
return false, (SLOT_NAMES[slot] or ("Slot " .. slot)) .. ": \"" .. piece.name .. "\" is a "
.. (SLOT_NAMES[piece.slot] or "other") .. " piece (from an outfit saved with an older ported pack?), "
.. "so your own gear shows there."
end
if not HasVirtualItems() then
return false, "Ported items need DmorphHelper v11: close the game, double-click \"Install DmorphHelper.bat\" "
.. "in Interface\\AddOns\\DmorphOcto, then start the game from OctoLauncher."
end
RegisterPortedItems()
local p = PortedItems()[id]
local template = PortedTemplate(p)
if not template then
return false, "The game hasn't loaded an item to copy for this ported " .. string.lower(M.ITEM_TYPE_NAMES[p.kind] or "item")
.. " yet. Wait a few seconds and pick it again."
end
if template ~= p.template then
p.template = template
pcall(DmorphHelper_SetVirtualItem, id, template, p.display)
end
end
return Morph(SetUnitVisibleItemID, "player", slot, id)
end
-- Mount morphs need VanillaHelpers' SetUnitMountDisplayID (v1.1.0 has it).
local function HasMountAPI()
return type(SetUnitMountDisplayID) == "function"
end
M.HasMountAPI = HasMountAPI
-- Mount skins -----------------------------------------------------------------
-- A skin is a display ID your mount shows instead of its own: db.mount for all
-- your mounts, db.mountSkins[real display] for one of them. VanillaHelpers'
-- mount override holds the model whatever the server says, so it would keep
-- you on a mount after you dismount. So the skin is only put on while you
-- ride: when the mount field turns non-zero you mounted, and the buff that
-- came with it is the mount's; when that buff goes, you dismounted and the
-- override comes off, which lets the game's own mount and dismount through.
local watch = {}
local function BuffTextures()
local set, any = {}, false
for i = 1, 32 do
local ok, texture = pcall(UnitBuff, "player", i)
if not (ok and texture) then
break
end
set[texture], any = true, true
end
return set, any
end
local function SkinFor(real)
return db.mountSkins[real] or db.mount
end
local function StopSkin()
if watch.active then
watch.active, watch.skin = nil, nil
Morph(SetUnitMountDisplayID, "player", nil)
end
end
local function RememberRidden(real)
for i = 1, table.getn(db.ridden) do
if db.ridden[i] == real then
return
end
end
table.insert(db.ridden, real)
if table.getn(db.ridden) > 20 then
table.remove(db.ridden, 1)
end
end
-- Runs every tenth of a second.
local function OnTaxi()
local ok, flying = pcall(UnitOnTaxi, "player")
return ok and flying and true or false
end
local function WatchMount()
if not (sessionReady and HasMountAPI()) then
return
end
-- A flight path's bird is the game's, not your mount: no skin on it (and
-- one already on comes off as the flight starts).
if OnTaxi() then
if watch.active then
StopSkin()
Notify()
end
watch.real = nil
return
end
if watch.active then
local buffs = BuffTextures()
for texture in pairs(watch.candidates) do
if not buffs[texture] then
-- The mount's buff is gone: you dismounted.
StopSkin()
watch.last, watch.real = buffs, nil
Notify()
return
end
end
-- Each change tells the listeners (the window, and Share.lua so other
-- DMorph users see the skin come and go).
local skin = SkinFor(watch.real)
if not skin then
StopSkin()
Notify()
elseif skin ~= watch.skin then
watch.skin = skin
Morph(SetUnitMountDisplayID, "player", skin)
Notify()
end
return
end
local ok, _, _, field = pcall(UnitDisplayInfo, "player")
if not ok then
return
end
if field and field > 0 then
if watch.real ~= field then
watch.real = field
RememberRidden(field)
Notify()
end
local skin = SkinFor(field)
if skin and skin ~= field then
local buffs, any = BuffTextures()
local candidates, found = {}, false
for texture in pairs(buffs) do
if not (watch.last and watch.last[texture]) then
candidates[texture], found = true, true
end
end
-- The mount's buff comes with it. Without a new one there's
-- nothing that would say when you dismount (a skin would then
-- stay on, and the game would think you still ride), so wait
-- for it; only if you were mounted before we could look (a
-- login or /reload in the saddle) does any buff going count.
if not found and not watch.last then
candidates, found = buffs, any
end
if found then
watch.candidates, watch.skin, watch.active = candidates, skin, true
Morph(SetUnitMountDisplayID, "player", skin)
Notify()
end
end
else
if watch.real then
watch.real = nil
Notify()
end
if db.mount or next(db.mountSkins) then
watch.last = BuffTextures()
end
end
end
-- Whether you ride, the display ID of your own mount, and the skin it shows.
function M.GetMountState()
InitDB()
if watch.real then
return true, watch.real, watch.skin
end
return false
end
-- The mounts you've ridden on this character (their own display IDs).
function M.GetRiddenMounts()
InitDB()
return db.ridden
end
function M.GetMountSkin(real)
InitDB()
if real then
return db.mountSkins[real]
end
return db.mount
end
-- What other DMorph users should see: your body's display ID (nil when you
-- aren't morphed), your item morphs (slot -> item ID), and the mount skin
-- showing now (nil on foot or without a skin).
function M.GetSharedLook()
InitDB()
local mount
if watch.active then
mount = watch.skin
end
-- Ported models are only on this PC: others would see nothing, so they
-- see your own model or mount instead.
local display = BodyDisplayID()
if display and M.IsPorted(display) then
display = nil
end
if mount and M.IsPorted(mount) then
mount = nil
end
local items = {}
for slot, id in pairs(db.items) do
if not M.IsPortedItem(id) then
items[slot] = id
end
end
return display, items, mount
end
-- Your picked weapon glows for other DMorph users (2.23.0, user: "weapon glows
-- are not sharing"): slot (16-18) -> enchant ID, 0 for "No glow". A glow only
-- hovered in the Glows tab isn't sent.
function M.GetSharedGlows()
InitDB()
local glowsOut = {}
for slot in pairs(GLOW_SLOT) do
if db.glows[slot] then
glowsOut[slot] = db.glows[slot]
end
end
return glowsOut
end
-- Whether this PC can show a glow someone sent: 0 (no glow) or an enchant in
-- the glow list (a ported one only with the ported pack's glows).
function M.IsKnownGlow(enchantID)
if enchantID == 0 then
return true
end
local all = M.GetGlows()
if not all.byEnchant then
all.byEnchant = {}
for i = 1, all.count do
all.byEnchant[all.enchant[i]] = true
end
end
return all.byEnchant[enchantID] or false
end
-- The ported parts of your look (display, items, mount), which only DMorph
-- users with the same ported pack can show; nil where a part isn't ported.
function M.GetSharedPorted()
InitDB()
local display, mount = BodyDisplayID(), nil
if watch.active then
mount = watch.skin
end
if not (display and M.IsPorted(display)) then
display = nil
end
if not (mount and M.IsPorted(mount)) then
mount = nil
end
local items = {}
for slot, id in pairs(db.items) do
if M.IsPortedItem(id) then
items[slot] = id
end
end
return display, items, mount
end
-- Glows and your own look (DmorphHelper.dll) ----------------------------------
-- DmorphHelper.dll (helper/ in this folder) does two things VanillaHelpers
-- can't, on your screen only like every morph: it rewrites a weapon slot's
-- enchant IDs, which the game draws the enchant's glow from, and your skin
-- colour, face, hair style, hair colour and facial hair (the values character
-- creation sets). The server sends the real values again now and then (a new
-- enchant, a loading screen), so a watcher puts the chosen ones back once a
-- second. Whatever it reads that it didn't write is the real value, kept so
-- "your own" can put it back.
-- How to install it: the .bat in the addon folder copies the DLL into the
-- game folder and adds it to dlls.txt (an addon can't write files there).
local INSTALL_HELPER = "Close the game, double-click \"Install DmorphHelper.bat\" in Interface\\AddOns\\DmorphOcto, "
.. "then start the game from OctoLauncher."
local MISSING_HELPER = "DmorphHelper.dll isn't loaded, so weapon glows, your size and real skin, face and hair changes "
.. "are unavailable. " .. INSTALL_HELPER
M.MISSING_HELPER = MISSING_HELPER
-- The DmorphHelper_Version() the DLL in helper\ reports. An older one in the
-- game folder (DMorph was updated, the DLL wasn't copied again) is flagged.
local HELPER_VERSION = 16
-- Rows 1-5 of the Races tab, in DmorphHelper_GetLook's order.
local OWN_LOOK_NPC = { "npcSkin", "npcFace", "npcHairStyle", "npcHairColour" }
local function HasHelper()
return type(DmorphHelper_GetItemEnchants) == "function" and type(DmorphHelper_SetItemEnchants) == "function"
and type(DmorphHelper_GetLook) == "function" and type(DmorphHelper_SetLook) == "function"
end
M.HasHelper = HasHelper
-- The DmorphHelper version running (nil: none), and the one this DMorph
-- comes with.
function M.GetHelperVersion()
if not HasHelper() then
return nil, HELPER_VERSION
end
local ok, version = pcall(DmorphHelper_Version)
return ok and tonumber(version) or 0, HELPER_VERSION
end
-- What DMorph needs and what it can use, for the window's Setup check and
-- /dmorph check: { name, state = "ok" / "bad" / "off" (optional, missing), text }.
function M.GetSetupReport()
local report = {}
local function Add(name, state, text)
table.insert(report, { name = name, state = state, text = text })
end
if HasMorphAPI() then
local version = ""
if type(VANILLAHELPERS_VERSION) == "number" then
version = " v" .. VersionText(VANILLAHELPERS_VERSION)
end
Add("VanillaHelpers", "ok", "Running" .. version .. ": morphing works.")
else
Add("VanillaHelpers", "bad", MissingAPI())
end
local helperVersion = M.GetHelperVersion()
if helperVersion and helperVersion < HELPER_VERSION then
Add("DmorphHelper", "bad", "An older DmorphHelper (v" .. helperVersion .. ") is in the game folder; this DMorph "
.. "comes with v" .. HELPER_VERSION .. ". " .. INSTALL_HELPER)
elseif HasHelper() then
local poses = ""
if type(DmorphHelper_GetRiderPose) == "function" then
local ok, _, works = pcall(DmorphHelper_GetRiderPose, 1)
if ok and works == 1 then
poses = " Riders stand on discs, boards and carpets."
else
poses = " It couldn't change the riding pose (another mod changed that part of the game?)."
end
end
Add("DmorphHelper", "ok", "Running v" .. helperVersion .. " (this DMorph comes with v" .. HELPER_VERSION .. "): weapon "
.. "glows, size, real skin, face and hair, and your own faction and languages during race morphs work." .. poses)
else
Add("DmorphHelper", "off", "Optional, not running: weapon glows and real skin, face and hair need it. "
.. INSTALL_HELPER)
end
if type(C_Item) == "table" then
Add("ClassicAPI", "ok", "Running: 3D previews of models work.")
else
Add("ClassicAPI", "off", "Optional, not running: the Models tab shows a list instead of 3D previews.")
end
if SUPERWOW_VERSION then
Add("SuperWoW", "ok", "Running: other DMorph users' morphs show without targeting them.")
else
Add("SuperWoW", "off", "Optional, not running: target or mouse over other DMorph users to see their morphs.")
end
return report
end
-- Every glow the client can draw (Data/Glows.lua): per glow its visual ID, an
-- enchant ID that shows it, its name (the shortest enchant name), all the
-- enchant names and its effect models. Then the porting tool's glows from
-- later expansions (Data/Ported.lua's DmorphOcto_PortedGlows, same format;
-- its patch adds the enchants), marked ported.
local glows
function M.GetGlows()
local text, portedText = DmorphOcto_GlowData or "", DmorphOcto_PortedGlows or ""
if not glows or glows.text ~= text or glows.portedText ~= portedText then
glows = { text = text, portedText = portedText, count = 0, visual = {}, enchant = {}, name = {}, names = {},
models = {}, search = {}, ported = {} }
local sources = { text, portedText }
for s = 1, 2 do
for visual, enchant, names, models in string.gfind(sources[s], "(%d+)\t(%d+)\t([^\t\n]*)\t([^\t\n]*)") do
local n = glows.count + 1
glows.count = n
glows.visual[n], glows.enchant[n] = tonumber(visual), tonumber(enchant)
local _, _, first = strfind(names, "^([^;]+)")
glows.name[n] = first or ("Glow " .. visual)
glows.names[n], glows.models[n] = names, models
glows.search[n] = strlower(names .. " " .. models .. " " .. visual .. " " .. enchant)
if s == 2 then
glows.ported[n] = true
glows.search[n] = glows.search[n] .. " ported"
end
end
end
end
return glows
end
-- The glows whose names, effect models or IDs have every word of text.
function M.FilterGlows(text)
local all, words = M.GetGlows(), {}
for word in string.gfind(strlower(text or ""), "%S+") do
table.insert(words, word)
end
local result, count = {}, 0
for i = 1, all.count do
local keep = true
for k = 1, table.getn(words) do
if not strfind(all.search[i], words[k], 1, true) then
keep = false
break
end
end
if keep then
count = count + 1
result[count] = i
end
end
return result, count
end
function M.DescribeGlow(enchantID)
if enchantID == 0 then
return "no glow"
end
local all = M.GetGlows()
for i = 1, all.count do
if all.enchant[i] == enchantID then
return all.name[i]
end
end
return "enchant " .. enchantID
end
-- The glow picked for a weapon slot (an enchant ID, 0 for none, nil for your
-- own) and the slot's real permanent enchant, if seen.
function M.GetGlow(slot)
InitDB()
local real = db.glowReal[slot]
return db.glows[slot], real and real[1]
end
-- The skin colours, faces and hair a race and gender can wear
-- (Data/CharOptions.lua), or nil.
function M.GetLookOptions(race, gender)
local all = DmorphOcto_CharOptions
local o = type(all) == "table" and race and all[race .. ":" .. gender]
if type(o) == "table" and type(o.skin) == "table" and table.getn(o.skin) > 0 then
return o
end
end
-- The values row f (1-5) can have with the rest of look, sorted: a face needs
-- its skin colour, a hair colour its hair style.
local function LookChoices(o, look, f)
local list
if f == 1 then
list = o.skin
elseif f == 2 then
list = o.face and o.face[look[1]]
elseif f == 3 then
list = o.hairStyle
elseif f == 4 then
list = o.hairColour and o.hairColour[look[3]] or o.anyHairColour
else
list = o.facialHair
end
return type(list) == "table" and list or {}
end
local function Nearest(list, value)
local best
for i = 1, table.getn(list) do
local v = list[i]
if v == value then
return v
end
if not best or math.abs(v - value) < math.abs(best - value) then
best = v
end
end
return best or value
end
-- Makes look one the race can wear, row by row; returns the rows changed.
local function ClampLook(o, look)
local changed = {}
for f = 1, 5 do
local v = Nearest(LookChoices(o, look, f), look[f])
if v ~= look[f] then
look[f] = v
table.insert(changed, f)
end
end
return changed
end
-- Row f of a look: its choices, the value's place among them (0 if it isn't
-- one), and whether only NPCs have that value.
function M.LookRow(o, look, f)
local values = LookChoices(o, look, f)
local pos = 0
for i = 1, table.getn(values) do
if values[i] == look[f] then
pos = i
end
end
local npc = OWN_LOOK_NPC[f] and type(o[OWN_LOOK_NPC[f]]) == "table" and o[OWN_LOOK_NPC[f]][look[f]] and true
return values, pos, npc or false
end
local function SameValues(a, b)
if not (a and b) then
return false
end
for i = 1, 5 do
if a[i] ~= b[i] then
return false
end
end
return true
end
local function CopyLook(look)
return { look[1], look[2], look[3], look[4], look[5] }
end
local function ReadLook()
local ok, a, b, c, d, e = pcall(DmorphHelper_GetLook, "player")
if ok and type(a) == "number" then
return { a, b, c, d, e }
end
end
-- Your real look: what the game has, unless that's what we wrote.
local function RealLook()
local current = HasHelper() and ReadLook()
if current and not SameValues(current, db.lookWritten) then
return current
end
return db.lookReal or current
end
-- The body your look shows on (race, gender), its options, the look it has
-- (5 values, or nil when unknown) and whether that's one you picked.
function M.GetLookState()
InitDB()
local race, gender = M.GetBodyRace()
local o = M.GetLookOptions(race, gender)
local picked = race and db.looks[race .. ":" .. gender]
if picked then
return race, gender, o, picked, true
end
return race, gender, o, M.FittedLook(o, RealLook()), false
end
-- Your own look as the body's race can wear it: a value that race doesn't
-- have (a Tauren's hair style on a Gnome) has no texture, and the game
-- draws it green, so it moves to the closest one that race has.
function M.FittedLook(o, look)
if not (o and look) then
return look
end
local fitted = CopyLook(look)
ClampLook(o, fitted)
return fitted
end
-- Your size needs DmorphHelper v2.
local function HasScale()
return type(DmorphHelper_GetScale) == "function" and type(DmorphHelper_SetScale) == "function"
end
M.HasScale = HasScale
-- Puts the picked size on (the server sends the real one again now and
-- then), and the real one back once nothing is picked.
local function WatchScale()
if not HasScale() then
return
end
local ok, current = pcall(DmorphHelper_GetScale, "player")
if not (ok and type(current) == "number") then
return
end
local written = db.scaleWritten
if not (written and math.abs(current - written) < 0.001) then
db.scaleReal = current
end
local want = db.scale or (written and db.scaleReal)
if want and math.abs(current - want) >= 0.001 and pcall(DmorphHelper_SetScale, "player", want) then
db.scaleWritten = want
end
if not db.scale then
db.scaleWritten = nil
end
end
-- Puts the picked glows and look on, and puts the real ones back where
-- nothing is picked any more.
-- Item morphs the game put back. VanillaHelpers keeps an item morph against
-- the server's writes, but in combat some slots showed the real item again
-- (user report, 14 Oct: head, waist, legs, feet, sometimes hands while
-- tanking; shoulders, chest and weapons stayed), and asking it again changes
-- nothing while it still holds the morph. DmorphHelper v13 also returns the
-- item a slot shows: a morphed slot showing another item gets its morph again
-- (VanillaHelpers then writes it, as the value differs).
-- Each repair reloads your model. A slot the game puts back again and again
-- would reload it every second, which can leave you running on the spot
-- (user, 14 Oct): after 5 repairs of one slot in 10 seconds that slot is left
-- alone for a minute, with a note. /dmorph itemcheck off|on|log; /dmorph
-- status counts the repairs.
local itemCheck = { on = true, log = false, clock = 0, count = {}, recent = {}, pausedUntil = {} }
M.itemCheck = itemCheck
local function CheckItems()
if not (sessionReady and HasHelper()) or not itemCheck.on then
return
end
local now = itemCheck.clock
for slot, item in pairs(db.items) do
local ok, _, _, shown = pcall(DmorphHelper_GetItemEnchants, "player", slot)
if ok and type(shown) == "number" and shown ~= item and (itemCheck.pausedUntil[slot] or -1) <= now then
local recent, kept = itemCheck.recent[slot] or {}, {}
for i = 1, table.getn(recent) do
if now - recent[i] < 10 then
table.insert(kept, recent[i])
end
end
if table.getn(kept) >= 5 then
itemCheck.pausedUntil[slot] = now + 60
itemCheck.recent[slot] = {}
Print((SLOT_NAMES[slot] or ("Slot " .. slot)) .. ": the game keeps switching it back (it shows item " .. shown
.. "), so DMorph leaves it alone for a minute rather than reload your model nonstop.")
else
table.insert(kept, now)
itemCheck.recent[slot] = kept
itemCheck.count[slot] = (itemCheck.count[slot] or 0) + 1
if itemCheck.log then
Print((SLOT_NAMES[slot] or ("Slot " .. slot)) .. " showed item " .. shown .. ": put " .. item .. " back.")
end
ApplyItem(slot)
end
end
end
end
M.CheckItems = CheckItems
-- Your gear's durability changing (hits taken, a death, a repair) makes the
-- game redraw those body parts from your real items although the morphs are
-- still on, so CheckItems sees nothing wrong (users, 14-15 Oct: "every time
-- the durability on the items changes the original skin is visible again";
-- head, waist, legs, feet, hands while tanking). The model is rebuilt from the
-- morphs then, as VanillaHelpers does when a morph goes on: at most once a
-- second, and gear events right after our own rebuild are ignored (no loop).
local function RebuildForItems()
if not (itemCheck.on and sessionReady and HasHelper() and type(DmorphHelper_Rebuild) == "function") or not next(db.items) then
itemCheck.rebuildPending = nil
return
end
if itemCheck.clock - (itemCheck.lastRebuild or -100) < 1 then
itemCheck.rebuildPending = true
return
end
itemCheck.rebuildPending = nil
itemCheck.lastRebuild = itemCheck.clock
itemCheck.ignoreUntil = itemCheck.clock + 0.5
if pcall(DmorphHelper_Rebuild, "player") then
itemCheck.rebuilds = (itemCheck.rebuilds or 0) + 1
if itemCheck.log then
Print("Your gear changed: your model was rebuilt with your item morphs.")
end
end
end
-- Glows hovered in the Glows tab (M.PreviewGlow), per slot.
local glowPreview = {}
local function WatchHelper()
if not (sessionReady and HasHelper()) or helperHold > 0 then
return
end
RepairRace()
CheckItems()
WatchScale()
local current = ReadLook()
if current then
if not SameValues(current, db.lookWritten) then
db.lookReal = current
end
local race, gender = M.GetBodyRace()
local want = race and db.looks[race .. ":" .. gender]
if not want and race then
-- Your own look, fitted to the race (see M.FittedLook).
local real = db.lookReal or current
local fitted = M.FittedLook(M.GetLookOptions(race, gender), real)
if fitted and not SameValues(fitted, real) then
want = fitted
end
end
local restore = not want and db.lookWritten
if restore then
want = db.lookReal
end
if want and not SameValues(current, want) then
if pcall(DmorphHelper_SetLook, "player", want[1], want[2], want[3], want[4], want[5]) then
db.lookWritten = CopyLook(want)
end
end
if restore then
db.lookWritten = nil
end
end
for slot in pairs(GLOW_SLOT) do
local ok, p, t = pcall(DmorphHelper_GetItemEnchants, "player", slot)
if ok and type(p) == "number" then
local written = db.glowWritten[slot]
if not (written and written[1] == p and written[2] == t) then
db.glowReal[slot] = { p, t }
end
local want
-- A glow hovered in the Glows tab shows until the mouse moves on (M.PreviewGlow).
local pick = glowPreview[slot] or db.glows[slot]
if pick then
want = { pick, 0 }
elseif written then
want = db.glowReal[slot]
end
if want and (want[1] ~= p or want[2] ~= t) then
if pcall(DmorphHelper_SetItemEnchants, "player", slot, want[1], want[2]) then
db.glowWritten[slot] = { want[1], want[2] }
end
end
if not pick then
db.glowWritten[slot] = nil
end
end
end
end
-- Shows a glow on a weapon without picking it (the Glows tab's hover, user
-- 19 Oct: the preview box can't draw enchants, your character can); nil
-- ends it and your picked or own glow comes back.
function M.PreviewGlow(slot, enchantID)
if not GLOW_SLOT[slot] or glowPreview[slot] == enchantID then
return
end
glowPreview[slot] = enchantID
WatchHelper()
end
-- Glow and look actions need both DLLs.
local function HelperBegin()
if not Begin() then
return false, MissingAPI()
end
if not HasHelper() then
return false, MISSING_HELPER
end
return true
end
-- Your size, 0.1-10 (1 = normal), or nil for your own. It works on any
-- model: a small Ragnaros, a giant Tauren.
function M.SetScale(scale)
local ok, err = HelperBegin()
if not ok then
return false, err
end
if not HasScale() then
return false, "Changing your size needs DmorphHelper v2 (this DMorph comes with it). " .. INSTALL_HELPER
end
if scale ~= nil then
scale = tonumber(scale)
if not (scale and scale >= 0.1 and scale <= 10) then
return false, "Size is a number from 0.1 to 10 (1 is normal)."
end
scale = math.floor(scale * 100 + 0.5) / 100
end
db.scale = scale
WatchScale()
Notify()
if scale then
return true, format("Size: %d%%. Only you see it.", math.floor(scale * 100 + 0.5))
end
return true, "Your own size again."
end
-- The size you picked (nil: your own) and the real one last seen.
function M.GetScale()
InitDB()
return db.scale, db.scaleReal
end
-- Weather (DmorphHelper v8) ---------------------------------------------------------
-- The game draws four kinds of weather, each from 0 to 1 strong (the server
-- picks per zone). A picked one stays on in every zone, on your screen only,
-- until you go back to the server's.
M.WEATHER_KINDS = { [0] = "Clear skies", "Rain", "Snow", "Sandstorm" }
M.WEATHERS = {
{ key = "Clear", kind = 0, intensity = 0, text = "Clear skies" },
{ key = "LightRain", kind = 1, intensity = 0.25, text = "Light rain" },
{ key = "Rain", kind = 1, intensity = 0.55, text = "Rain" },
{ key = "HeavyRain", kind = 1, intensity = 1, text = "Heavy rain" },
{ key = "LightSnow", kind = 2, intensity = 0.25, text = "Light snow" },
{ key = "Snow", kind = 2, intensity = 0.55, text = "Snow" },
{ key = "HeavySnow", kind = 2, intensity = 1, text = "Blizzard" },
{ key = "LightSandstorm", kind = 3, intensity = 0.25, text = "Light sandstorm" },
{ key = "Sandstorm", kind = 3, intensity = 0.55, text = "Sandstorm" },
{ key = "HeavySandstorm", kind = 3, intensity = 1, text = "Heavy sandstorm" },
}
local WEATHER_WORDS = { clear = 0, sun = 0, sunny = 0, fine = 0, none = 0, rain = 1, snow = 2, sand = 3,
sandstorm = 3, storm = 3, dust = 3 }
local WEATHER_STRENGTH = { light = 0.25, medium = 0.55, normal = 0.55, heavy = 1, strong = 1 }
local function HasWeather()
return type(DmorphHelper_SetWeather) == "function" and type(DmorphHelper_ClearWeather) == "function"
and type(DmorphHelper_GetWeather) == "function"
end
M.HasWeather = HasWeather
-- "Heavy rain (100%)", "Clear skies".
function M.DescribeWeather(kind, intensity)
local name = M.WEATHER_KINDS[kind] or "Unknown weather"
if not kind or kind == 0 then
return name
end
return format("%s (%d%%)", name, math.floor((intensity or 0) * 100 + 0.5))
end
-- Time of day (DmorphHelper v9): the world's light, sky, sun and moon at a
-- picked time; the clock and the server keep the real one.
M.TIMES = {
{ key = "Dawn", minutes = 360, text = "Dawn" },
{ key = "Noon", minutes = 720, text = "Noon" },
{ key = "Dusk", minutes = 1170, text = "Dusk" },
{ key = "Night", minutes = 0, text = "Midnight" },
}
local TIME_WORDS = { dawn = 360, sunrise = 360, morning = 540, day = 720, noon = 720, midday = 720,
afternoon = 900, dusk = 1170, sunset = 1170, evening = 1260, night = 0, midnight = 0 }
local function HasTime()
return type(DmorphHelper_SetTime) == "function" and type(DmorphHelper_GetTime) == "function"
end
M.HasTime = HasTime
-- "22:30".
function M.DescribeTime(minutes)
minutes = math.floor(minutes or 0)
return format("%d:%02d", math.floor(minutes / 60), math.mod(minutes, 60))
end
-- Puts the picked weather and time on (after a loading screen or zone
-- change, the helper has usually kept them; this is the backup).
local function ApplyWeather()
if db and db.weather and HasWeather() then
pcall(DmorphHelper_SetWeather, db.weather[1], db.weather[2])
end
if db and db.time and HasTime() then
pcall(DmorphHelper_SetTime, db.time)
end
end
-- minutes after midnight (0-1439), or nil for the real time.
function M.SetTime(minutes)
local ok, err = HelperBegin()
if not ok then
return false, err
end
if not HasTime() then
return false, "Changing the time of day needs DmorphHelper v9 (this DMorph comes with it). " .. INSTALL_HELPER
end
if minutes ~= nil then
minutes = tonumber(minutes)
if not minutes then
return false, "The time is hours and minutes, e.g. 22:30."
end
minutes = math.mod(math.floor(minutes + 0.5), 1440)
if minutes < 0 then
minutes = minutes + 1440
end
end
if not pcall(DmorphHelper_SetTime, minutes or -1) then
return false, "DmorphHelper couldn't change the time of day."
end
db.time = minutes
Notify()
if minutes then
return true, "Time of day: " .. M.DescribeTime(minutes) .. ". Only you see it."
end
return true, "The real time of day again."
end
-- The time you picked (minutes; nil: the real time) and the real time
-- (nil without DmorphHelper v9).
function M.GetTime()
InitDB()
local real
if HasTime() then
local ok, _, _, r = pcall(DmorphHelper_GetTime)
if ok then
real = r
end
end
return db.time, real
end
-- kind: 0 clear, 1 rain, 2 snow, 3 sandstorm, or nil for the server's
-- weather; intensity 0-1 (rain, snow and sandstorm need more than 0).
function M.SetWeather(kind, intensity)
local ok, err = HelperBegin()
if not ok then
return false, err
end
if not HasWeather() then
return false, "Changing the weather needs DmorphHelper v8 (this DMorph comes with it). " .. INSTALL_HELPER
end
if kind == nil then
if not pcall(DmorphHelper_ClearWeather) then
return false, "There's no weather to change outside the world."
end
db.weather = nil
Notify()
return true, "The server's weather again."
end
kind = tonumber(kind)
if not (kind and kind >= 0 and kind <= 3 and kind == math.floor(kind)) then
return false, "Weather is 0 (clear), 1 (rain), 2 (snow) or 3 (sandstorm)."
end
intensity = tonumber(intensity) or 0.55
if kind == 0 then
intensity = 0
elseif not (intensity >= 0.05 and intensity <= 1) then
return false, "Weather strength is 5% to 100%."
end
intensity = math.floor(intensity * 100 + 0.5) / 100
if not pcall(DmorphHelper_SetWeather, kind, intensity) then
return false, "There's no weather to change outside the world."
end
db.weather = { kind, intensity }
Notify()
return true, "Weather: " .. M.DescribeWeather(kind, intensity) .. ". Only you see it, outdoors."
end
-- The weather you picked (kind, intensity; nil: the server's) and the
-- server's (kind, intensity; nil without DmorphHelper v8).
function M.GetWeather()
InitDB()
local serverKind, serverIntensity
if HasWeather() then
local ok, _, _, _, k, i = pcall(DmorphHelper_GetWeather)
if ok then
serverKind, serverIntensity = k, i
end
end
if db.weather then
return db.weather[1], db.weather[2], serverKind, serverIntensity
end
return nil, nil, serverKind, serverIntensity
end
-- enchantID: an enchant whose glow the slot shows, 0 for no glow, nil for
-- your own.
function M.SetGlow(slot, enchantID)
local ok, err = HelperBegin()
if not ok then
return false, err
end
if not GLOW_SLOT[slot] then
return false, "Glows show on weapons: main hand (16), off hand (17) or ranged (18)."
end
if enchantID ~= nil then
enchantID = ParseID(enchantID)
if not enchantID or enchantID > 1000000 then
return false, "Enchant IDs are whole numbers."
end
end
db.glows[slot] = enchantID
glowPreview[slot] = nil -- picked now: the hover look is over
WatchHelper()
Notify()
if not enchantID then
return true, SLOT_NAMES[slot] .. ": your own glow."
end
return true, SLOT_NAMES[slot] .. ": " .. M.DescribeGlow(enchantID) .. "."
end
M.LOOK_ROWS = { "Skin colour", "Face", "Hair style", "Hair colour", "Facial hair" }
local function RowName(f, gender)
if f == 5 and gender == 1 then
return "Features"
end
return M.LOOK_ROWS[f]
end
M.LookRowName = RowName
local function NoOptions(race, gender)
if not race then
return "Couldn't detect your race, pick one first."
end
return "There's no skin, face and hair list for " .. M.DescribeBody(race, gender)
.. " yet. Close the game and run tools\\update_from_game.ps1, then try again."
end
-- "the face and hair colour"
local function JoinRows(rows, gender)
local names = {}
for i = 1, table.getn(rows) do
table.insert(names, strlower(RowName(rows[i], gender)))
end
local n = table.getn(names)
if n == 1 then
return names[1]
end
return table.concat(names, ", ", 1, n - 1) .. " and " .. names[n]
end
local function PickLook(race, gender, look)
db.looks[race .. ":" .. gender] = CopyLook(look)
WatchHelper()
Notify()
end
-- Your model (race morph or your own) with these five values, made valid for
-- it. Returns ok, message.
function M.SetOwnLook(values)
local ok, err = HelperBegin()
if not ok then
return false, err
end
local race, gender, o = M.GetLookState()
if not o then
return false, NoOptions(race, gender)
end
local look = CopyLook(values)
local changed = ClampLook(o, look)
PickLook(race, gender, look)
if table.getn(changed) > 0 then
return true, M.DescribeBody(race, gender) .. " has no such " .. JoinRows(changed, gender)
.. ", so the closest one is on."
end
return true, "Look set."
end
-- Row f (1-5) of your model's look one value along (step 1 or -1, wrapping).
-- A row whose new value doesn't go with another row (a face is per skin
-- colour, a hair colour per hair style) moves that one to its closest value.
function M.StepOwnLook(f, step)
local ok, err = HelperBegin()
if not ok then
return false, err
end
local race, gender, o, current = M.GetLookState()
if not o then
return false, NoOptions(race, gender)
end
if not current then
return false, "Couldn't read your look from the game."
end
local look = CopyLook(current)
ClampLook(o, look)
local values = LookChoices(o, look, f)
local n = table.getn(values)
if n < 2 then
return false, M.DescribeBody(race, gender) .. " has just one " .. strlower(RowName(f, gender)) .. "."
end
local pos = 1
for i = 1, n do
if values[i] == look[f] then
pos = i
end
end
pos = math.mod(pos - 1 + step + n, n) + 1
look[f] = values[pos]
local changed = ClampLook(o, look)
PickLook(race, gender, look)
local message = RowName(f, gender) .. " " .. pos .. " of " .. n
local _, _, npc = M.LookRow(o, look, f)
if npc then
message = message .. " (an NPC-only one)"
end
message = message .. "."
if table.getn(changed) > 0 then
message = message .. " The " .. JoinRows(changed, gender) .. " changed too: this one doesn't come with the old one."
end
return true, message
end
-- A random look for your model, from everything its race can wear.
function M.RandomOwnLook()
local ok, err = HelperBegin()
if not ok then
return false, err
end
local race, gender, o, current = M.GetLookState()
if not o then
return false, NoOptions(race, gender)
end
local look = CopyLook(current or { 0, 0, 0, 0, 0 })
for try = 1, 5 do
for f = 1, 5 do
local values = LookChoices(o, look, f)
if table.getn(values) > 0 then
look[f] = values[math.random(table.getn(values))]
end
end
if not SameValues(look, current) then
break
end
end
PickLook(race, gender, look)
return true, "A random " .. M.DescribeBody(race, gender) .. " look."
end
-- Back to your real skin, face and hair on the model you show.
function M.ResetOwnLook()
local ok, err = HelperBegin()
if not ok then
return false, err
end
local race, gender = M.GetBodyRace()
if race then
db.looks[race .. ":" .. gender] = nil
end
WatchHelper()
Notify()
return true, "Your own face, skin and hair."
end
local function ApplySaved()
if not Begin() then
return
end
if BodyDisplayID() then
ApplyBody()
end
for slot in pairs(db.items) do
ApplyItem(slot)
end
WatchHelper()
ApplyWeather()
Notify()
end
-- Turns the outcome of the VanillaHelpers calls into (ok, message).
local function Result(ok, err, message)
Notify()
if ok then
return true, message
end
return false, "|cffff5555" .. tostring(err) .. "|r"
end
-- Actions: each returns ok, message -----------------------------------------
function M.ResetModel()
if not Begin() then
return false, MissingAPI()
end
db.display, db.race, db.gender = nil, nil, 0
db.looks = {}
local ok, err = ApplyBody()
WatchHelper()
return Result(ok, err, "Model reset.")
end
function M.ResetAll()
if not Begin() then
return false, MissingAPI()
end
db.display, db.race, db.gender = nil, nil, 0
db.looks, db.glows, db.scale = {}, {}, nil
local ok, err = ApplyBody()
WatchHelper()
db.mount, db.mountSkins = nil, {}
StopSkin()
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
db.items[slot] = nil
local slotOK, slotErr = ApplyItem(slot)
if ok and not slotOK then
ok, err = slotOK, slotErr
end
end
return Result(ok, err, "Model and items reset.")
end
function M.SetDisplay(displayID)
if not Begin() then
return false, MissingAPI()
end
displayID = ParseID(displayID)
if not displayID then
return false, "Display IDs are whole numbers."
end
if displayID == 0 then
return M.ResetModel()
end
db.display, db.race, db.gender = displayID, nil, 0
local ok, err = ApplyBody()
return Result(ok, err, "Morphed into " .. M.DescribeDisplay(displayID) .. ".")
end
-- The race and gender your model shows now, morphed or your own.
function M.GetBodyRace()
InitDB()
if db.race then
return db.race, db.gender
end
return nativeRace, nativeGender or 0
end
-- gender is 0 or 1; anything else keeps the current gender.
function M.SetRace(race, gender)
if not Begin() then
return false, MissingAPI()
end
if not RACES[race] then
return false, "Unknown race. Races: " .. RaceList()
end
if gender ~= 0 and gender ~= 1 then
local _
_, gender = M.GetBodyRace()
end
if race == nativeRace and gender == nativeGender then
db.display, db.race, db.gender = nil, nil, 0
else
db.display, db.race, db.gender = nil, race, gender
end
local ok, err = ApplyBody()
return Result(ok, err, "Race: " .. M.DescribeBody(race, gender) .. ".")
end
function M.ToggleGender()
if not Begin() then
return false, MissingAPI()
end
local race, gender = M.GetBodyRace()
if not race then
return false, "Couldn't detect your race, pick one first."
end
return M.SetRace(race, 1 - gender)
end
-- itemID 0 resets the slot.
function M.SetItem(slot, itemID)
if not Begin() then
return false, MissingAPI()
end
if not SLOT_NAMES[slot] then
return false, "Neck, rings and trinkets don't show on your model. Slots: " .. SlotList()
end
itemID = ParseID(itemID)
if not itemID then
return false, "Item IDs are whole numbers."
end
if itemID == 0 then
itemID = nil
end
db.items[slot] = itemID
local ok, err = ApplyItem(slot)
if itemID then
return Result(ok, err, SLOT_NAMES[slot] .. ": " .. M.DescribeItem(itemID, slot) .. ".")
end
return Result(ok, err, SLOT_NAMES[slot] .. " reset.")
end
-- Shows nothing in an armour slot (item 0, as under a set with your own gear
-- hidden): the slot is invisible on your character. SetItem(slot, 0) resets it.
function M.HideItem(slot)
if not Begin() then
return false, MissingAPI()
end
if not SLOT_NAMES[slot] or slot == 16 or slot == 17 or slot == 18 then
return false, "Only armour slots can be made invisible."
end
db.items[slot] = 0
local ok, err = ApplyItem(slot)
return Result(ok, err, SLOT_NAMES[slot] .. ": invisible.")
end
-- Birthday suit (user, 18 Oct): every armour slot shows nothing, to build a look
-- up from bare skin; weapons keep what they show. Use my own gear / Reset brings it back.
function M.BirthdaySuit()
if not Begin() then
return false, MissingAPI()
end
local ok, err = true, nil
for i = 1, table.getn(M.SLUTMOG_SLOTS) do
local slot = M.SLUTMOG_SLOTS[i]
if db.items[slot] ~= 0 then
db.items[slot] = 0
local slotOK, slotErr = ApplyItem(slot)
if ok and not slotOK then
ok, err = slotOK, slotErr
end
end
end
return Result(ok, err, "Birthday suit: every armour slot shows nothing. Pick pieces to build up from here.")
end
-- Gives your mount a skin: any creature model, shown whenever you ride. real
-- (a display ID from GetRiddenMounts) sets it for that mount only, else for
-- all your mounts. displayID 0 takes the skin off.
function M.SetMountSkin(displayID, real)
if not Begin() then
return false, MissingAPI()
end
if not HasMountAPI() then
return false, "This VanillaHelpers can't morph mounts: update it to v1.1.0 or newer."
end
displayID = ParseID(displayID)
if not displayID then
return false, "Display IDs are whole numbers."
end
if displayID == 0 then
displayID = nil
end
local target = "all your mounts"
if real then
db.mountSkins[real] = displayID
target = M.DescribeDisplay(real)
else
db.mount = displayID
end
WatchMount()
Notify()
if not displayID then
return true, "Skin for " .. target .. " removed."
end
local when = " It shows when you mount."
if watch.active then
when = ""
end
return true, "Skin for " .. target .. ": " .. M.DescribeDisplay(displayID) .. "." .. when
end
-- The skin for all your mounts (.mount, favourites).
function M.SetMount(displayID)
return M.SetMountSkin(displayID)
end
-- Your own gear under sets: by default the armour slots a set has no piece
-- for show nothing (item 0), so your real gear doesn't show through (user,
-- 14 Oct: their pink robe looked like "a purple filler" under 5-piece sets).
-- DmorphOctoAccount.setsKeepOwnGear keeps your own gear there instead.
local HIDE_UNDER_SETS = { 1, 3, 4, 5, 6, 7, 8, 9, 10, 15, 19 }
function M.GetHideGearUnderSets()
InitDB()
return not account.setsKeepOwnGear
end
function M.SetHideGearUnderSets(on)
InitDB()
account.setsKeepOwnGear = not on or nil
if on then
return true, "Sets hide your own gear in the slots they have no piece for (from the next set you wear)."
end
return true, "Sets keep your own gear in the slots they have no piece for (from the next set you wear)."
end
-- Puts on every piece of set `index` (its position in GetSetCatalog) whose
-- slot is known. A piece of any set (the last one worn, or one picked on its
-- own) in a slot this set has no piece for comes off first, so sets don't mix
-- (user, 13 Oct: the last set's helmet stayed on); other items stay. With
-- M.GetHideGearUnderSets (the default), armour slots the set has no piece for
-- and that show your own gear then show nothing.
function M.WearSet(index)
if not Begin() then
return false, MissingAPI()
end
local catalog = M.GetSetCatalog()
local pieces = catalog.pieces[index]
if not pieces then
return false, "Pick a set in the list first."
end
if not catalog.isPiece then
catalog.isPiece = {}
for i = 1, catalog.count do
for k = 1, table.getn(catalog.pieces[i]) do
catalog.isPiece[catalog.pieces[i][k]] = true
end
end
end
local used, slotOf, worn, unknown = {}, {}, 0, 0
for i = 1, table.getn(pieces) do
local slots = M.GetItemSlots(pieces[i])
if slots then
-- A second one-hand weapon goes in the off hand.
for k = 1, table.getn(slots) do
if not used[slots[k]] then
used[slots[k]], slotOf[i] = true, slots[k]
break
end
end
else
unknown = unknown + 1
end
end
-- The look the set makes, worked out for every slot first; then only the
-- slots that differ change (each change reloads your model, and clicking
-- a set again used to put its hidden slots back and hide them again, so
-- pieces seemed to toggle at random: user, 15 Oct). With your own gear
-- hidden (the default) every armour slot the set has no piece for shows
-- nothing, also an item picked on its own: a set shows only its pieces.
-- Shown: set pieces and slots a set hid are cleared, other items stay.
-- Weapons the set has no piece for keep what they show.
local target = {}
for i = 1, table.getn(SLOT_ORDER) do
target[SLOT_ORDER[i]] = db.items[SLOT_ORDER[i]]
end
for i = 1, table.getn(pieces) do
if slotOf[i] then
worn = worn + 1
target[slotOf[i]] = pieces[i]
end
end
local hide, hidden = not account.setsKeepOwnGear, 0
if worn > 0 then
local armour = {}
for i = 1, table.getn(HIDE_UNDER_SETS) do
armour[HIDE_UNDER_SETS[i]] = true
end
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
local current = db.items[slot]
if not used[slot] then
if hide and armour[slot] then
target[slot] = 0
hidden = hidden + 1
elseif current and (catalog.isPiece[current] or current == 0) then
target[slot] = nil -- (0 = a slot the last set hid)
end
end
end
end
local ok, err, changed = true, nil, 0
if worn > 0 then
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
if target[slot] ~= db.items[slot] then
db.items[slot] = target[slot]
changed = changed + 1
local slotOK, slotErr = ApplyItem(slot)
if ok and not slotOK then
ok, err = slotOK, slotErr
end
end
end
-- Already wearing it: one rebuild puts back anything the game redrew.
if changed == 0 and HasHelper() and type(DmorphHelper_Rebuild) == "function" then
pcall(DmorphHelper_Rebuild, "player")
end
end
local later = ""
if unknown > 0 then
later = " " .. unknown .. " more still being looked up: try again in a minute."
end
if worn == 0 then
Notify()
return false, "None of " .. catalog.names[index] .. "'s pieces are known yet." .. later
end
local count = worn .. " pieces"
if worn == 1 then
count = "1 piece"
end
if hidden > 0 then
count = count .. ", your own gear hidden in " .. hidden .. " slots"
end
return Result(ok, err, "Wearing " .. catalog.names[index] .. " (" .. count .. ")." .. later)
end
-- Favourites ----------------------------------------------------------------
-- Models and mounts kept at hand, shared by all your characters: a list of
-- { id = displayID, mount = true or nil }, oldest first.
function M.GetFavourites()
InitDB()
return account.favourites
end
local function FindFavourite(displayID, mount)
local list = account.favourites
for i = 1, table.getn(list) do
if list[i].id == displayID and list[i].mount == mount then
return i
end
end
end
function M.IsFavourite(displayID, mount)
InitDB()
return FindFavourite(displayID, mount and true or nil) ~= nil
end
-- Adds the model (or, with mount set, the mount) to the favourites, or takes
-- it off if it's there already.
function M.ToggleFavourite(displayID, mount)
InitDB()
displayID = ParseID(displayID)
if not displayID or displayID == 0 then
return false, "Pick a model first."
end
mount = mount and true or nil
local what = "favourite models"
if mount then
what = "favourite mounts"
end
local index = FindFavourite(displayID, mount)
if index then
table.remove(account.favourites, index)
return true, "Removed " .. M.DescribeDisplay(displayID) .. " from your " .. what .. "."
end
table.insert(account.favourites, { id = displayID, mount = mount })
return true, "Added " .. M.DescribeDisplay(displayID) .. " to your " .. what .. "."
end
-- Wardrobe ------------------------------------------------------------------
function M.GetOutfitNames()
InitDB()
local names = {}
for name in pairs(account.outfits) do
table.insert(names, name)
end
table.sort(names, function(a, b)
return strlower(a) < strlower(b)
end)
return names
end
function M.GetOutfit(name)
InitDB()
return account.outfits[name]
end
-- The saved name matching text, ignoring case.
function M.FindOutfit(text)
InitDB()
text = strlower(Trim(text))
for name in pairs(account.outfits) do
if strlower(name) == text then
return name
end
end
end
function M.SaveOutfit(name)
InitDB()
name = Trim(name)
if name == "" then
return false, "Type a name for the outfit first."
end
local replaced = account.outfits[name] ~= nil
account.outfits[name] = CopyOutfit(db)
Notify()
if replaced then
return true, "Updated outfit \"" .. name .. "\"."
end
return true, "Saved outfit \"" .. name .. "\"."
end
-- Puts on the whole outfit: slots it doesn't morph go back to your own gear.
function M.LoadOutfit(name)
if not Begin() then
return false, MissingAPI()
end
local outfit = account.outfits[name]
if not outfit then
return false, "There is no outfit called \"" .. tostring(name) .. "\"."
end
db.display, db.race, db.gender = outfit.display, outfit.race, outfit.gender
if db.race == nativeRace and db.gender == nativeGender then
db.race, db.gender = nil, 0
end
local ok, err = ApplyBody()
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
db.items[slot] = outfit.items[slot]
local slotOK, slotErr = ApplyItem(slot)
if ok and not slotOK then
ok, err = slotOK, slotErr
end
end
db.mount = outfit.mount
-- The outfit's glows, and its look for its body (else your own).
db.glows = {}
for slot, enchantID in pairs(outfit.glows) do
db.glows[slot] = enchantID
end
local race, gender = M.GetBodyRace()
if race then
db.looks[race .. ":" .. gender] = nil
end
local look = outfit.look
if look and look.race == race and look.gender == gender then
db.looks[race .. ":" .. gender] = ValidLook(look)
end
WatchHelper()
return Result(ok, err, "Loaded outfit \"" .. name .. "\".")
end
function M.DeleteOutfit(name)
InitDB()
if not account.outfits[name] then
return false, "There is no outfit called \"" .. tostring(name) .. "\"."
end
account.outfits[name] = nil
Notify()
return true, "Deleted outfit \"" .. name .. "\"."
end
-- Chat commands -------------------------------------------------------------
local function ShowHelp()
Print("v" .. VERSION .. " - /dmorph opens the morph window. Chat commands:")
Print(".morph <displayID> - morph into any model (.morph 0 resets)")
Print(".race <1-10> - " .. RaceList())
Print(".gender - swap the gender of your race or race morph")
Print(".item <slot> <itemID or item link> - morph a visible item (itemID 0 resets the slot). Slots: " .. SlotList())
Print(".demorph - reset your model (DMorph's F5); .demorph all - reset model and items (F10)")
Print(".outfit [name] - list or put on saved outfits; .outfit save <name>; .outfit delete <name>")
Print(".mount <displayID> - morph your mount (.mount 0 resets)")
Print(".scale <size> - your size, e.g. .scale 0.5 or .scale 2 (.scale 0 resets; needs DmorphHelper)")
Print(".weather <clear/rain/snow/sandstorm> [light/heavy/5-100%] - the weather on your screen (.weather off: the server's; needs DmorphHelper)")
Print(".time <hh:mm or dawn/noon/dusk/night> - the time of day on your screen (.time off: the real one; needs DmorphHelper)")
Print(".glow <mainhand/offhand/ranged> <enchantID> - a weapon's enchant glow (0 none, own your own); needs DmorphHelper.dll")
Print(".share on/off - whether other DMorph users see your morph; .share others on/off - whether you see theirs")
Print(".dmorph status - show what is morphed; .dmorph check - is everything DMorph needs running, and how to fix it. Keys: Key Bindings > DMorph.")
if not HasMorphAPI() then
Print(MissingAPI())
end
end
local function ShowStatus()
if not HasMorphAPI() then
Print(MissingAPI())
elseif type(VANILLAHELPERS_VERSION) == "number" then
Print("VanillaHelpers v" .. VersionText(VANILLAHELPERS_VERSION) .. " is loaded.")
end
local helperVersion = M.GetHelperVersion()
if helperVersion and helperVersion < HELPER_VERSION then
Print("DmorphHelper v" .. helperVersion .. " is loaded, an older one: this DMorph comes with v" .. HELPER_VERSION .. ". "
.. INSTALL_HELPER)
elseif helperVersion then
Print("DmorphHelper v" .. helperVersion .. " is loaded: weapon glows, size and real skin, face and hair work.")
else
Print("DmorphHelper.dll is not loaded: weapon glows and real skin, face and hair changes need it. " .. INSTALL_HELPER)
end
if nativeRace then
Print("Your character: " .. M.DescribeBody(nativeRace, nativeGender) .. ".")
end
if db.display then
Print("Model: " .. M.DescribeDisplay(db.display) .. ".")
elseif db.race then
Print("Model: " .. M.DescribeBody(db.race, db.gender) .. ".")
else
Print("Model: not morphed.")
end
local list = ""
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
if db.items[slot] then
list = list .. ", " .. SLOT_NAMES[slot] .. " " .. M.DescribeItem(db.items[slot], slot)
end
end
if list == "" then
Print("Items: not morphed.")
else
Print("Items: " .. strsub(list, 3) .. ".")
end
for i = 1, table.getn(M.GLOW_SLOTS) do
local slot = M.GLOW_SLOTS[i]
if db.glows[slot] then
Print("Glow, " .. strlower(SLOT_NAMES[slot]) .. ": " .. M.DescribeGlow(db.glows[slot]) .. ".")
end
end
local bodies = {}
for key in pairs(db.looks) do
local _, _, race, gender = strfind(key, "^(%d+):(%d)$")
table.insert(bodies, M.DescribeBody(tonumber(race), tonumber(gender)))
end
if table.getn(bodies) > 0 then
table.sort(bodies)
Print("Own face, skin and hair picked for: " .. table.concat(bodies, ", ") .. ".")
end
if db.mount then
Print("Mount skin: " .. M.DescribeDisplay(db.mount) .. ".")
end
if db.scale then
Print(format("Size: %d%%.", math.floor(db.scale * 100 + 0.5)))
end
if db.weather then
Print("Weather: " .. M.DescribeWeather(db.weather[1], db.weather[2]) .. ".")
end
if db.time then
Print("Time of day: " .. M.DescribeTime(db.time) .. ".")
end
local repaired = {}
for i = 1, table.getn(SLOT_ORDER) do
local slot = SLOT_ORDER[i]
if itemCheck.count[slot] then
table.insert(repaired, SLOT_NAMES[slot] .. " " .. itemCheck.count[slot])
end
end
if not itemCheck.on then
Print("Item check: off (/dmorph itemcheck on).")
elseif table.getn(repaired) > 0 then
Print("Item morphs the game switched back and DMorph put back this session: " .. table.concat(repaired, ", ") .. ".")
end
if raceRepairs > 0 then
Print("Race byte left on the wrong race and put right this session: " .. raceRepairs .. " times.")
end
for real, skin in pairs(db.mountSkins) do
Print("Skin for " .. M.DescribeDisplay(real) .. ": " .. M.DescribeDisplay(skin) .. ".")
end
end
local commands = {}
-- Other files (Share.lua) add their own chat commands here.
M.commands = commands
function commands.morph(args)
local displayID = ParseID(args[1] or "0")
if not displayID then
return Print("Usage: .morph <displayID> (.morph 0 resets)")
end
Report(M.SetDisplay(displayID))
end
function commands.race(args)
local word = strlower(args[1] or "")
local race = RACE_ALIASES[word] or ParseID(word)
if race == 11 then
return Print("Draenei don't exist in the 1.12 client. Races: " .. RaceList())
end
if not race or not RACES[race] then
return Print("Usage: .race <1-10> (" .. RaceList() .. ")")
end
Report(M.SetRace(race))
end
function commands.gender()
Report(M.ToggleGender())
end
function commands.item(args, line)
local word = strlower(args[1] or "")
local slot = SLOT_ALIASES[word] or ParseID(word)
if not slot or slot < 1 or slot > 19 then
return Print("Usage: .item <slot> <itemID> (slots: " .. SlotList() .. ")")
end
-- Accept a shift-clicked item link as well as a plain ID.
local _, _, linkID = strfind(line, "item:(%d+)")
local itemID = ParseID(linkID or args[2] or "0")
if not itemID then
return Print("Usage: .item <slot> <itemID> (itemID 0 resets the slot)")
end
Report(M.SetItem(slot, itemID))
end
function commands.mount(args)
local displayID = ParseID(args[1] or "0")
if not displayID then
return Print("Usage: .mount <displayID> (.mount 0 resets)")
end
Report(M.SetMount(displayID))
end
-- .glow mainhand 1900, .glow offhand 0 (no glow), .glow ranged own.
function commands.glow(args)
local word = strlower(args[1] or "")
local slot = SLOT_ALIASES[word] or ({ mh = 16, oh = 17 })[word] or ParseID(word)
local value = strlower(args[2] or "")
local enchantID = ParseID(value)
if not (slot and GLOW_SLOT[slot]) or not (enchantID or value == "own") then
return Print("Usage: .glow <mainhand/offhand/ranged> <enchantID> (0 for no glow, own for your own)")
end
Report(M.SetGlow(slot, enchantID))
end
commands.enchant = commands.glow
function commands.scale(args)
local word = strlower(args[1] or "")
if word == "" or word == "0" or word == "reset" or word == "own" then
return Report(M.SetScale(nil))
end
-- .scale 1.5 or .scale 150%
local _, _, number, percent = strfind(word, "^([%d%.]+)(%%?)$")
local scale = tonumber(number or "")
if scale and percent == "%" then
scale = scale / 100
end
if not scale then
return Print("Usage: .scale <size>, e.g. .scale 0.5 (half), .scale 2 or .scale 200% (double); .scale 0 resets.")
end
Report(M.SetScale(scale))
end
-- .time 22:30, .time 9, .time night, .time off
function commands.time(args)
local word = strlower(args[1] or "")
if word == "off" or word == "real" or word == "server" or word == "reset" then
return Report(M.SetTime(nil))
end
local minutes = TIME_WORDS[word]
if not minutes then
local _, _, hours, mins = strfind(word, "^(%d%d?)[:%.]?(%d?%d?)$")
hours, mins = tonumber(hours or ""), tonumber(mins ~= "" and mins or "0")
if hours and mins and hours < 24 and mins < 60 then
minutes = hours * 60 + mins
end
end
if not minutes then
return Print("Usage: .time <hh:mm or dawn/noon/dusk/night>, e.g. .time 22:30; .time off for the real time.")
end
Report(M.SetTime(minutes))
end
-- .weather rain heavy, .weather snow 40, .weather 3 0.8, .weather off
function commands.weather(args)
local word = strlower(args[1] or "")
if word == "off" or word == "server" or word == "reset" or word == "own" then
return Report(M.SetWeather(nil))
end
local kind = WEATHER_WORDS[word] or tonumber(word)
local strength = strlower(args[2] or "")
local intensity = WEATHER_STRENGTH[strength]
if not intensity and strength ~= "" then
local _, _, number, percent = strfind(strength, "^([%d%.]+)(%%?)$")
intensity = tonumber(number or "")
if intensity and (percent == "%" or intensity > 1) then
intensity = intensity / 100
end
end
if not kind or (strength ~= "" and not intensity) then
return Print("Usage: .weather <clear/rain/snow/sandstorm> [light/heavy or 5-100%], e.g. .weather rain heavy; "
.. ".weather off for the server's weather.")
end
Report(M.SetWeather(kind, intensity))
end
function commands.demorph(args)
if strlower(args[1] or "") == "all" then
return Report(M.ResetAll())
end
Report(M.ResetModel())
end
function commands.outfit(args, line)
local _, _, rest = strfind(line, "^%S+%s*(.-)%s*$")
local action = strlower(args[1] or "")
local _, _, name = strfind(rest, "^%S+%s*(.-)$")
if rest == "" then
local names = M.GetOutfitNames()
if table.getn(names) == 0 then
return Print("No outfits saved yet. Save your current look with .outfit save <name>.")
end
return Print("Outfits: " .. table.concat(names, ", ") .. ".")
elseif action == "save" then
return Report(M.SaveOutfit(name))
elseif action == "delete" then
return Report(M.DeleteOutfit(M.FindOutfit(name) or name))
end
Report(M.LoadOutfit(M.FindOutfit(rest) or rest))
end
-- /dmorph check: is everything DMorph uses running, and how to fix what isn't.
local CHECK_COLOURS = { ok = "|cff33ff99", bad = "|cffff5555", off = "|cffaaaaaa" }
local CHECK_WORDS = { ok = "OK", bad = "NOT WORKING", off = "not running" }
local function ShowCheck()
local report = M.GetSetupReport()
for i = 1, table.getn(report) do
local entry = report[i]
Print(entry.name .. ": " .. CHECK_COLOURS[entry.state] .. CHECK_WORDS[entry.state] .. "|r - " .. entry.text)
end
end
function commands.dmorph(args)
local action = strlower(args[1] or "")
if action == "status" then
return ShowStatus()
elseif action == "check" then
return ShowCheck()
elseif action == "itemcheck" then
local what = strlower(args[2] or "")
if what == "off" then
itemCheck.on = false
return Print("Item check off: DMorph no longer puts item morphs back that the game switched back (until /reload or /dmorph itemcheck on).")
elseif what == "on" then
itemCheck.on, itemCheck.log = true, false
itemCheck.pausedUntil = {}
return Print("Item check on.")
elseif what == "log" then
itemCheck.on, itemCheck.log = true, true
return Print("Item check on, and every item morph it puts back is named in chat.")
end
return Print("Usage: /dmorph itemcheck on | off | log")
elseif action == "" and M.ToggleUI then
return M.ToggleUI()
end
ShowHelp()
end
-- Runs a command line such as "morph 4527". Returns false when the first word
-- isn't one of ours, so the caller can pass the text on untouched.
local function RunCommand(line)
local args = {}
for word in string.gfind(line, "%S+") do
table.insert(args, word)
end
local name = strlower(table.remove(args, 1) or "")
local handler = commands[name]
if not handler then
return false
end
InitDB()
if HasMorphAPI() then
EnsureSession()
end
handler(args, line)
return true
end
-- Chat that is spoken in a language (the rest, like emotes, isn't).
local SPOKEN = {
SAY = true, YELL = true, PARTY = true, RAID = true, RAID_WARNING = true, GUILD = true,
OFFICER = true, WHISPER = true, CHANNEL = true, BATTLEGROUND = true,
}
-- A race morph also changes your race in VanillaHelpers, and the game then
-- speaks that race's language by default and lists that race's languages: as
-- an Undead morphed into a Gnome, "You don't know that language" (Common).
-- So chat goes out in a language your own character speaks (read with your
-- own race showing, see CaptureNative): the one asked for if you speak it,
-- else your own default, else your faction's (Orcish / Common), else the first.
local function SpeakableLanguage(language)
local list, default = db and db.languages, db and db.defaultLanguage
if not list then
-- Not read yet (no morph this session): the game's own list is right.
list = {}
for i = 1, 20 do
local ok, name = pcall(GetLanguageByIndex, i)
if not (ok and name) then
break
end
table.insert(list, name)
end
local ok, live = pcall(GetDefaultLanguage)
default = ok and live or nil
end
local known, count = {}, table.getn(list)
for i = 1, count do
known[list[i]] = true
end
if count == 0 or (language and known[language]) then
return language
end
if default and known[default] then
return default
end
local faction = UnitFactionGroup("player")
if faction == "Horde" and known["Orcish"] then
return "Orcish"
elseif faction == "Alliance" and known["Common"] then
return "Common"
end
return list[1]
end
-- DMorph's interface: commands typed into chat with a leading dot. Anything
-- that isn't one of our commands still goes to the server as before, in a
-- language you know (see above).
local orig_SendChatMessage = SendChatMessage
function SendChatMessage(msg, chatType, language, channel)
if type(msg) == "string" then
local _, _, line = strfind(msg, "^%s*%.(.*)$")
if line and RunCommand(line) then
return
end
end
if SPOKEN[strupper(chatType or "SAY")] then
language = SpeakableLanguage(language)
end
return orig_SendChatMessage(msg, chatType, language, channel)
end
SLASH_DMORPHOCTO1 = "/dmorph"
SlashCmdList["DMORPHOCTO"] = function(msg)
local _, _, line = strfind(msg or "", "^%s*%.?(.*)$")
if not RunCommand(line) then
RunCommand("dmorph " .. line)
end
end
-- Key bindings (Bindings.xml); the reset keys stand in for DMorph's F5/F10.
BINDING_HEADER_DMORPHOCTO = "DMorph"
BINDING_NAME_DMORPHOCTO_TOGGLE = "Open or close the DMorph window"
BINDING_NAME_DMORPHOCTO_RESET = "Reset model (DMorph F5)"
BINDING_NAME_DMORPHOCTO_RESETALL = "Reset model and items (DMorph F10)"
function DmorphOcto_ToggleUI()
RunCommand("dmorph")
end
function DmorphOcto_Reset()
RunCommand("demorph")
end
function DmorphOcto_ResetAll()
RunCommand("demorph all")
end
-- Events --------------------------------------------------------------------
local frame = CreateFrame("Frame")
frame:Hide()
frame:RegisterEvent("VARIABLES_LOADED")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:SetScript("OnEvent", function()
InitDB()
if event ~= "PLAYER_ENTERING_WORLD" then
return
end
if not HasMorphAPI() then
if not warnedMissingAPI then
warnedMissingAPI = true
Print(MissingAPI())
Print("Open the window (/dmorph) and click Setup, or type /dmorph check, to see what's running.")
end
return
end
-- Fires after every loading screen; give the client a moment to build the
-- player model before re-applying the saved morph.
pendingApply = 0.5
frame:Show()
end)
frame:SetScript("OnUpdate", function()
pendingApply = pendingApply - arg1
if pendingApply <= 0 then
frame:Hide()
raceHeld = {} -- a loading screen closed the windows that held it
ApplySaved()
end
end)
-- Your own race while the flight map, spellbook or reputation list is open
-- (see HoldRace). Blizzard's TaxiFrame handles TAXIMAP_OPENED before us, so
-- once the race is back it draws the flight paths again. That alone left the
-- map empty (user, 18 Oct): the game works out which paths you can fly when
-- the flight master's list arrives, before this event, so DmorphHelper v16
-- does that with your own race (its TaxiBuildRoutes hook); the spellbook and
-- reputation list are held before they read their spells and standings.
local held = {}
local function HoldWhileShown(frame, reason)
if not frame or held[reason] then
return
end
held[reason] = true
local onShow, onHide = frame:GetScript("OnShow"), frame:GetScript("OnHide")
frame:SetScript("OnShow", function()
M.HoldRace(reason, true)
if onShow then
onShow()
end
end)
frame:SetScript("OnHide", function()
if onHide then
onHide()
end
M.HoldRace(reason, false)
end)
end
local raceFrame = CreateFrame("Frame")
raceFrame:RegisterEvent("TAXIMAP_OPENED")
raceFrame:RegisterEvent("TAXIMAP_CLOSED")
raceFrame:RegisterEvent("PLAYER_LOGIN")
raceFrame:SetScript("OnEvent", function()
if event == "TAXIMAP_OPENED" then
if M.HoldRace("taxi", true) and TaxiFrame and type(TaxiFrame_OnEvent) == "function" then
local saved = this
this = TaxiFrame
pcall(TaxiFrame_OnEvent, "TAXIMAP_OPENED")
this = saved
end
elseif event == "TAXIMAP_CLOSED" then
M.HoldRace("taxi", false)
elseif event == "PLAYER_LOGIN" then
HoldWhileShown(SpellBookFrame, "spellbook")
HoldWhileShown(ReputationFrame, "reputation")
end
end)
-- Item lookup runs in the background from shortly after login until every
-- pending item has an answer.
local lookupFrame = CreateFrame("Frame")
lookupFrame:Hide()
lookupFrame:SetScript("OnUpdate", function()
this.elapsed = this.elapsed + arg1
if this.elapsed >= LOOKUP_STEP then
if LookupTick(this.elapsed) then
this:Hide()
end
this.elapsed = 0
end
end)
lookupFrame:SetScript("OnEvent", function()
LookupAnswer(arg1, arg2)
end)
local loginFrame = CreateFrame("Frame")
loginFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
loginFrame:SetScript("OnEvent", function()
this:UnregisterEvent("PLAYER_ENTERING_WORLD")
InitDB()
StartLookup()
if CanAskServer() then
pcall(lookupFrame.RegisterEvent, lookupFrame, "ITEM_DATA_LOAD_RESULT")
end
lookupFrame.elapsed = -15 -- let the login settle first
lookupFrame:Show()
end)
-- A picked weather: the helper keeps it when the server sends a zone's
-- weather; entering a new zone puts it on again in case none came.
local weatherFrame = CreateFrame("Frame")
weatherFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
weatherFrame:SetScript("OnEvent", function()
ApplyWeather()
end)
-- The mount watcher: puts the mount skin on while you ride (see Mount skins).
local mountFrame = CreateFrame("Frame")
mountFrame:SetScript("OnUpdate", function()
this.wait = (this.wait or 0) - arg1
if this.wait <= 0 then
this.wait = 0.1
WatchMount()
KeepRace()
end
end)
-- Losing and getting back control (a flight path starts and lands, a fear,
-- a stun) takes the skin off: if you're still on your mount, the watcher
-- sees its buff and puts the skin on again a tenth of a second later.
mountFrame:RegisterEvent("PLAYER_CONTROL_LOST")
mountFrame:RegisterEvent("PLAYER_CONTROL_GAINED")
mountFrame:SetScript("OnEvent", function()
if watch.active then
StopSkin()
watch.real = nil
Notify()
end
end)
-- Whether the game can describe every item your morphs show (a ported one
-- through its template), so a model built now has them all.
local LOGIN_WAIT = 12
local function MorphedItemsLoaded()
for _, id in pairs(db.items) do
if id > 0 and not GetItemInfo(id) then
return false
end
end
return true
end
-- The durability of the item in a slot ("" for none or none shown), from a
-- hidden tooltip: 1.12 has neither UPDATE_INVENTORY_DURABILITY nor a function
-- that says it.
local durabilityTip, durabilityPattern
local function Durability(slot)
if not durabilityTip then
durabilityTip = CreateFrame("GameTooltip", "DmorphOctoDurabilityTip", nil, "GameTooltipTemplate")
durabilityPattern = string.gsub(DURABILITY_TEMPLATE or "Durability %d / %d", "%%d", "(%%d+)")
end
durabilityTip:SetOwner(UIParent, "ANCHOR_NONE")
durabilityTip:ClearLines()
durabilityTip:SetInventoryItem("player", slot)
for i = 2, durabilityTip:NumLines() do
local line = getglobal("DmorphOctoDurabilityTipTextLeft" .. i)
local _, _, now = strfind(line and line:GetText() or "", durabilityPattern)
if now then
return now
end
end
return ""
end
-- What you have on: the item in each slot (1-19) and the durability of the
-- morphed ones; and how much ammo and how many thrown weapons are left.
local function GearState()
local parts = {}
for slot = 1, 19 do
parts[slot] = GetInventoryItemLink("player", slot) or ""
if db.items[slot] then
parts[slot] = parts[slot] .. "@" .. Durability(slot)
end
end
return table.concat(parts, ";"), (GetInventoryItemCount("player", 0) or 0) .. "/" .. (GetInventoryItemCount("player", 18) or 0)
end
-- Your gear changing: item morphs are checked, and a fifth of a second later
-- checked again and the model rebuilt with them (RebuildForItems), when the
-- game may have redrawn a slot from your real item: another item put on, or a
-- morphed slot's durability changed. Not for anything else UNIT_INVENTORY_CHANGED
-- reports (bags, loot, food, every arrow, bullet or throw): rebuilding the
-- model while you move left you running on the spot or frozen, and on every
-- shot made a hunter blink and broke the shooting animation (users, 15-17 Oct).
--
-- 2.22.0 (user, 21 Oct: items still changed when losing durability, after a
-- spirit res or being revived, and after repairing): 1.12 doesn't fire
-- UNIT_INVENTORY_CHANGED for a durability change, so those redraws went
-- unseen. The morphed slots' durability is now also read once a second
-- (GEAR_POLL), and dying, releasing, being resurrected and closing a vendor
-- (repairs) check at once; coming back to life rebuilds twice (now and
-- RES_AGAIN seconds later), as the game builds your living model again after
-- the events.
local GEAR_POLL, RES_AGAIN = 1, 1.5
local itemFrame = CreateFrame("Frame")
-- What you have on (GearState) against the last look: a change gets the
-- morphs checked and, a fifth of a second later, the model rebuilt.
local function GearCheck(force)
if not next(db.items) then
itemCheck.gear, itemCheck.counts = GearState()
return
end
local gear, counts = GearState()
local changed = force or gear ~= itemCheck.gear
itemCheck.gear, itemCheck.counts = gear, counts
if itemCheck.clock < (itemCheck.ignoreUntil or -1) then
-- Right after our own rebuild (which changes none of this): a real
-- change still gets its rebuild, once the second is up.
if changed then
itemCheck.rebuildPending = true
end
return
end
CheckItems()
if changed then
itemFrame.wait = 0.2
end
end
M.GearCheck = GearCheck
itemFrame:RegisterEvent("UNIT_INVENTORY_CHANGED")
itemFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
itemFrame:RegisterEvent("PLAYER_DEAD")
itemFrame:RegisterEvent("PLAYER_ALIVE")
itemFrame:RegisterEvent("PLAYER_UNGHOST")
itemFrame:RegisterEvent("MERCHANT_CLOSED")
itemFrame:RegisterEvent("UPDATE_INVENTORY_ALERTS")
itemFrame:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" then
itemCheck.gear, itemCheck.counts = GearState()
-- The game can build your model before it has the items your morphs
-- show (a ported piece needs its template from the server), which
-- leaves you invisible, white in previews: then rebuilt once they're
-- all in (or after LOGIN_WAIT seconds), see below.
itemCheck.loginWait = not MorphedItemsLoaded() and 0 or nil
return
end
if event == "PLAYER_ALIVE" or event == "PLAYER_UNGHOST" then
-- Back to life (a spirit healer, a res, back at your body): rebuilt
-- now and once more when the game has built your living model.
GearCheck(true)
this.again = RES_AGAIN
return
end
if event == "UNIT_INVENTORY_CHANGED" and arg1 ~= "player" then
return
end
GearCheck()
end)
itemFrame:SetScript("OnUpdate", function()
-- Durability changes have no event: read them once a second.
this.poll = (this.poll or 0) + arg1
if this.poll >= GEAR_POLL then
this.poll = 0
if sessionReady and itemCheck.on and next(db.items) then
GearCheck()
end
end
if this.again then
this.again = this.again - arg1
if this.again <= 0 then
this.again = nil
GearCheck(true)
end
end
if itemCheck.loginWait then
itemCheck.loginWait = itemCheck.loginWait + arg1
if itemCheck.loginWait >= 1.5 and sessionReady and (MorphedItemsLoaded() or itemCheck.loginWait >= LOGIN_WAIT) then
itemCheck.loginWait = nil
RebuildForItems()
end
end
if this.wait then
this.wait = this.wait - arg1
if this.wait <= 0 then
this.wait = nil
CheckItems()
RebuildForItems()
end
elseif itemCheck.rebuildPending then
RebuildForItems()
end
end)
-- The DmorphHelper watcher: keeps the picked glows and look on (see Glows and
-- your own look).
local helperFrame = CreateFrame("Frame")
helperFrame:SetScript("OnUpdate", function()
helperHold = helperHold - arg1
itemCheck.clock = itemCheck.clock + arg1
helperWait = helperWait - arg1
if helperWait <= 0 then
helperWait = 1
WatchHelper()
end
end)