From 1bae2566a4544850384e49c7c80c4ce54f092de3 Mon Sep 17 00:00:00 2001 From: Uga Date: Sat, 1 Aug 2026 20:08:47 +0200 Subject: [PATCH 1/4] Port account sharing to the current comm and DataStore APIs Account sharing still called APIs that disappeared when Altoholic stopped being an AceAddon and DataStore moved off AceDB, so the very first step of a share failed with "Comm.lua:81: attempt to call a nil value". Comm: Whisper() called Altoholic:SendCommMessage(), an AceComm-3.0 mixin that no longer exists; it now goes through DataStore:SendChatMessage(). Nothing registered the "AltoShare" prefix either, so the receiving side was never reached even when a message got out; AccSharingHandler is now a plain callback registered with DataStore:OnGuildComm() at load. DataStore: GetModuleLastUpdate(), ImportData() and ImportCharacter() still indexed module.Characters[key] and addon.db.global, whereas GetCharacterTable() had already been ported to the id-based schema. They now resolve the character id and read/write the module tables listed in sharedTables, and ImportData() allocates an id for an unknown key so imported alts become visible. Faction and guild are no longer stored per character, so ImportCharacter() only takes the key and the transfer no longer tries to read them off the payload. CopyTable() recursed into the global CopyTable(), which returns a copy instead of filling the destination, so every nested table was silently dropped. Altoholic: the sharing UI and SetLastAccountSharingInfo() read Altoholic.db.global.Sharing, now Altoholic_Sharing_Options. The domain entry is created on demand instead of being assumed to exist, and the summary refresh is guarded since Altoholic_Summary is load on demand. Co-Authored-By: Claude Opus 5 --- DataStore/API/Core.lua | 58 +++++++++++++++++++++++---------------- DataStore/API/Utility.lua | 12 ++++++-- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/DataStore/API/Core.lua b/DataStore/API/Core.lua index 5259457..533db08 100644 --- a/DataStore/API/Core.lua +++ b/DataStore/API/Core.lua @@ -395,12 +395,18 @@ function addon:GetCharacterTable(module, name, realm, account) end function addon:GetModuleLastUpdate(module, name, realm, account) - module = GetModuleTable(module) + -- Can the module be shared? + local moduleTables = sharedTables[module] + if not moduleTables then return end - local key = GetKey(name, realm, account) - if key then - return module.Characters[key].lastUpdate - end + -- the first table of a module is the one holding its character data + local characterTable = _G[moduleTables[1]] + if not characterTable then return end + + local charID = addon:GetCharacterID(GetKey(name, realm, account)) + if not charID then return end + + return characterTable[charID] and characterTable[charID].lastUpdate end function addon:GetModuleLastUpdateByKey(moduleName, key) @@ -419,31 +425,37 @@ function addon:GetModuleLastUpdateByKey(moduleName, key) end function addon:ImportData(module, data, name, realm, account) - module = GetModuleTable(module) - - -- CopyTable is necessary rather than assignment, without it, ace DB wildcards are not applied. - addon:CopyTable(data, module.Characters[GetKey(name, realm, account)]) + -- Can the module be shared? + local moduleTables = sharedTables[module] + if not moduleTables then return end + + -- data was packed by GetCharacterTable: data[module][moduleIndex] = the module table of that character + local importedTables = data and data[module] + if not importedTables then return end + + -- be sure an id exists for the imported character, both tables are indexed by it + local charID = addon:StoreToSetAndList(DataStore_CharacterIDs, GetKey(name, realm, account)) + + for moduleIndex, importedTable in pairs(importedTables) do + local tableName = moduleTables[moduleIndex] + local destination = tableName and _G[tableName] + + if destination then + -- CopyTable is necessary rather than assignment, the source table belongs to the serializer. + destination[charID] = {} + addon:CopyTable(importedTable, destination[charID]) + end + end end -function addon:ImportCharacter(key, faction, guild) +function addon:ImportCharacter(key) -- after data has been imported, add a player entry to the DB, so that it becomes "visible" to the outside world. -- in other words, the correct sequence of operations should be something like: -- DataStore:ImportData(DataStore_Talents) -- DataStore:ImportData(DataStore_Spells) - -- DataStore:ImportCharacter(key, faction, guild) + -- DataStore:ImportCharacter(key) - local characters = addon.db.global.Characters - - characters[key].faction = faction - characters[key].guildName = guild - - -- Ensure a key is created for every module, even those which were not imported. - -- Required for proper UI support without extra validation of every method - addon:IterateDBModules(function(moduleDB) - if moduleDB.Characters then - moduleDB.Characters[key].lastUpdate = time() - end - end) + return addon:StoreToSetAndList(DataStore_CharacterIDs, key) end diff --git a/DataStore/API/Utility.lua b/DataStore/API/Utility.lua index 2c3431a..796cd28 100644 --- a/DataStore/API/Utility.lua +++ b/DataStore/API/Utility.lua @@ -47,18 +47,24 @@ function addon:SortedArrayClone(array) return clone end -function addon:CopyTable(source, destination) +local function CopyTableInto(source, destination) for k, v in pairs(source) do - + if type(v) == "table" then destination[k] = {} - CopyTable(v, destination[k]) + CopyTableInto(v, destination[k]) else destination[k] = v end end end +function addon:CopyTable(source, destination) + -- note: do not call the global CopyTable() for nested tables, it returns a new table + -- instead of filling the destination, which silently dropped every sub-table. + CopyTableInto(source, destination) +end + function addon:ArrayInsertUnique(array, value) -- Check if the element already exists in the array for _, v in ipairs(array) do From 32f1b25497d52ba911c9d54714ae25a81ddfaa5b Mon Sep 17 00:00:00 2001 From: Uga Date: Sat, 1 Aug 2026 20:08:48 +0200 Subject: [PATCH 2/4] Only share DataStore tables that are indexed by character id sharedTables, the list of what account sharing transfers, mixed character tables with tables keyed by something else entirely. GetCharacterTable reads every entry as _G[tableName][charID], so those were sent as if they were the character's data, and ImportData wrote them back at the recipient's own char id: - DataStore_Containers_Guilds is a guild table, so a guild bank was overwritten whenever the recipient happened to have a guild with that id - DataStore_Currencies_Info and _Max are keyed by currency id, and DataStore_Crafts_RecipeCategories by category id, all small integers that collide with character ids. The last one holds a plain string, which also made CopyTable() fail on pairs() - DataStore_Spells_Tabs is keyed by class name, _Currencies_Catalog and _Headers are Set/List references, DataStore_Talents_SpecializationInfos is keyed by specialization id: never the character's data GetCharacterTable also assumed every listed table exists. On classic the talent covenant tables are not created at all, so sharing DataStore_Talents errored on indexing a nil global; it now skips missing tables. ImportData assumed every transferred value is a table, which is not true of the characterIdTables (a specialization id, a bank type); a plain value is now assigned directly instead of being fed to CopyTable. Co-Authored-By: Claude Opus 5 --- DataStore/API/Core.lua | 88 +++++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/DataStore/API/Core.lua b/DataStore/API/Core.lua index 533db08..4978b29 100644 --- a/DataStore/API/Core.lua +++ b/DataStore/API/Core.lua @@ -325,30 +325,39 @@ local function GetModuleTable(module) end +-- Tables that account sharing is allowed to transfer, per module. +-- Only list tables that are indexed by character id (ie: registered as characterTables or characterIdTables). +-- Guild tables are indexed by guild id, and reference tables by a currency id, a category id, a class name, etc.. +-- an entry of theirs at [charID] belongs to something else entirely, and sending it would overwrite +-- unrelated data on the receiving side. +-- The index of a table in this list is what identifies it in the transferred payload, so both ends must +-- run the same version. Append new tables at the end of a module rather than inserting them. local sharedTables = { - DataStore = { - -- "DataStore_GuildIDs", - -- "DataStore_GuildFactions", - -- "DataStore_CharacterIDs", - "DataStore_CharacterGUIDs", - -- "DataStore_CharacterGuilds", + DataStore = { + -- "DataStore_GuildIDs", + -- "DataStore_GuildFactions", + -- "DataStore_CharacterIDs", + "DataStore_CharacterGUIDs", + -- "DataStore_CharacterGuilds", -- "DataStore_AltGroups", "DataStore_ConnectedRealms", "DataStore_RealmNames" }, DataStore_Achievements = { "DataStore_Achievements_Characters" }, DataStore_Auctions = { "DataStore_Auctions_Characters", "DataStore_Auctions_AuctionsList", "DataStore_Auctions_BidsList" }, DataStore_Characters = { "DataStore_Characters_Info" }, - DataStore_Containers = { - "DataStore_Containers_Characters", "DataStore_Containers_Banks", "DataStore_Containers_Guilds", "DataStore_Containers_Reagents", + DataStore_Containers = { + -- "DataStore_Containers_Guilds" is a guild table + "DataStore_Containers_Characters", "DataStore_Containers_Banks", "DataStore_Containers_Reagents", "DataStore_Containers_VoidStorage", "DataStore_Containers_Keystones", "DataStore_Containers_BankTypes" }, - DataStore_Crafts = { "DataStore_Crafts_Characters", "DataStore_Crafts_ArcheologyItems", "DataStore_Crafts_RecipeCategories" }, - DataStore_Currencies = { - "DataStore_Currencies_Characters", "DataStore_Currencies_Catalog", "DataStore_Currencies_Info", "DataStore_Currencies_Max", - "DataStore_Currencies_Headers", "DataStore_Currencies_Archeology" + -- "DataStore_Crafts_RecipeCategories" is indexed by category id + DataStore_Crafts = { "DataStore_Crafts_Characters", "DataStore_Crafts_ArcheologyItems" }, + DataStore_Currencies = { + -- "DataStore_Currencies_Catalog" & "_Headers" are Set/List references, "_Info" & "_Max" are indexed by currency id + "DataStore_Currencies_Characters", "DataStore_Currencies_Archeology" }, - DataStore_Garrisons = { - "DataStore_Garrisons_Characters", "DataStore_Garrisons_Missions", "DataStore_Garrisons_MissionInfos", - "DataStore_Garrisons_Followers", "DataStore_Garrisons_FollowerNamesToID", "DataStore_Garrisons_Buildings", + DataStore_Garrisons = { + "DataStore_Garrisons_Characters", "DataStore_Garrisons_Missions", "DataStore_Garrisons_MissionInfos", + "DataStore_Garrisons_Followers", "DataStore_Garrisons_FollowerNamesToID", "DataStore_Garrisons_Buildings", "DataStore_Garrisons_CovenantSanctum", "DataStore_Garrisons_CypherEquipment", "DataStore_Garrisons_Shipments" }, DataStore_Inventory = { "DataStore_Inventory_Characters" }, @@ -357,41 +366,43 @@ local sharedTables = { --, "DataStore_Quests_History", "DataStore_Quests_Progress", "DataStore_Quests_Dailies", "DataStore_Quests_Weeklies", "DataStore_Quests_Colors", "DataStore_Quests_Infos" }, DataStore_Reputations = { "DataStore_Reputations_Characters" }, - DataStore_Spells = { "DataStore_Spells_Characters", "DataStore_Spells_Tabs" }, + -- "DataStore_Spells_Tabs" is indexed by class name + DataStore_Spells = { "DataStore_Spells_Characters" }, DataStore_Stats = { "DataStore_Stats_Characters" --, "DataStore_Stats_Weekly", "DataStore_Stats_Dungeons" }, - DataStore_Talents = { - "DataStore_Talents_Characters", "DataStore_Talents_Specializations", "DataStore_Talents_SpecializationInfos", - "DataStore_Talents_Covenant", "DataStore_Talents_Conduits", "DataStore_Talents_ConduitSpecs", + DataStore_Talents = { + -- "DataStore_Talents_SpecializationInfos" is indexed by specialization id + "DataStore_Talents_Characters", "DataStore_Talents_Specializations", + "DataStore_Talents_Covenant", "DataStore_Talents_Conduits", "DataStore_Talents_ConduitSpecs", "DataStore_Talents_Soulbinds", "DataStore_Talents_Reasons" }, } function addon:GetCharacterTable(module, name, realm, account) -- Can the module be shared? - if not sharedTables[module] then return {} end - + local moduleTables = sharedTables[module] + if not moduleTables then return {} end + local charTable = {} local key = GetKey(name, realm, account) local charID = addon:GetCharacterID(key) - + -- Iterate tables in the current module - local moduleTables = sharedTables[module] - for moduleIndex, tableName in ipairs(moduleTables) do + -- the table may not exist at all, several of them are only created on retail + local sourceTable = _G[tableName] + -- do we have data for this character in the current table ? - if _G[tableName][charID] then - - -- we do, link it + if sourceTable and sourceTable[charID] then + + -- we do, link it charTable[module] = charTable[module] or {} - charTable[module][moduleIndex] = _G[tableName][charID] + charTable[module][moduleIndex] = sourceTable[charID] end end - + return charTable - -- return module.Characters[GetKey(name, realm, account)] - end function addon:GetModuleLastUpdate(module, name, realm, account) @@ -433,17 +444,22 @@ function addon:ImportData(module, data, name, realm, account) local importedTables = data and data[module] if not importedTables then return end - -- be sure an id exists for the imported character, both tables are indexed by it + -- be sure an id exists for the imported character, every module table is indexed by it local charID = addon:StoreToSetAndList(DataStore_CharacterIDs, GetKey(name, realm, account)) - for moduleIndex, importedTable in pairs(importedTables) do + for moduleIndex, importedData in pairs(importedTables) do local tableName = moduleTables[moduleIndex] local destination = tableName and _G[tableName] if destination then - -- CopyTable is necessary rather than assignment, the source table belongs to the serializer. - destination[charID] = {} - addon:CopyTable(importedTable, destination[charID]) + if type(importedData) == "table" then + -- CopyTable is necessary rather than assignment, the source table belongs to the serializer. + destination[charID] = {} + addon:CopyTable(importedData, destination[charID]) + else + -- character id tables may hold a plain value (ex: a specialization id) + destination[charID] = importedData + end end end end From 9d314cb6b81ec7560d09c72f31c192d588fb0bbe Mon Sep 17 00:00:00 2001 From: Uga Date: Sat, 1 Aug 2026 20:08:49 +0200 Subject: [PATCH 3/4] Resolve our own guild by the id it was registered under On a connected realm the guild may live on another realm of the group. OnPlayerGuildUpdate() registers it under the guild's realm, as reported by GetGuildInfo("player") and expanded by GetLongRealmName(), and stores that id in DataStore_CharacterGuilds. GetGuild() however rebuilt the key from the character's own realm, so for a character of Pyrewood Village in a guild homed on Nethergarde Keep the two disagree: Default.NethergardeKeep.ZERO = 1 <- what the writers use Default.Pyrewood Village.ZERO = 2 <- what GetGuild() returned Both keys end up in DataStore_GuildIDs, so the same guild has two ids. Every module writes through GetCharacterGuildID() and therefore fills the tables at the first id, while the guild members pane reads through GetGuild() and asks for the second. The guild tables are dispatched by id, and that dispatch bails out with a bare return when the table is missing, so GetGuildMemberAverageItemLevel and GetGuildMemberInventoryItem were never even called: every member showed an item level of 0.0 and no equipment at all. GetGuild() with no name now answers with the key of the guild the current character is registered in, which is by definition the id the writers use. The call that passes a name, realm and account is untouched, the search tab relies on it to resolve the guild of a bank result. This only aligns the readers with the writers, it does not stop the duplicate key from being created in the first place. Co-Authored-By: Claude Opus 5 --- DataStore/DataStore.lua | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/DataStore/DataStore.lua b/DataStore/DataStore.lua index ced59db..37a7b7d 100644 --- a/DataStore/DataStore.lua +++ b/DataStore/DataStore.lua @@ -261,9 +261,21 @@ end -- *** Guild functions *** function addon:GetGuild(name, realm, account) + -- No name ? the caller wants the guild of the current character, so answer with the guild it is + -- actually registered in, instead of rebuilding a key from its own realm. On connected realms the + -- guild may live on another realm of the group, and OnPlayerGuildUpdate() registered it under that + -- realm. Rebuilding the key from ThisRealm then creates a second key for the same guild, with a + -- second id, and every module that stored its data under the registered one looks empty. + if not name then + local guildID = _GetCharacterGuildID(addon.ThisCharKey) + local guildKey = guildID and allGuilds.List[guildID] + + if guildKey then return guildKey end + end + name = name or GetGuildInfo("player") local key = GetKey(name, realm, account) - + if allGuilds.Set[key] then -- if the key is known, return it to caller, it can be passed to other modules return key else -- if the key is not known, try checking the connected realm info From 6e17840dd993f4bab5b95f5566b868fb32a1b64f Mon Sep 17 00:00:00 2001 From: Uga Date: Sat, 1 Aug 2026 20:08:49 +0200 Subject: [PATCH 4/4] List our guilded alts by the guild id they were registered under Same connected realm mismatch as GetGuild(), one layer down. GetAlts() rebuilt the guild key from the character's own realm: local guildKey = GetKey(guild) local guildID = addon:StoreToSetAndList(DataStore_GuildIDs, guildKey) then compared it against DataStore_CharacterGuilds[charID] of every alt. Those were written by OnPlayerGuildUpdate() using the guild's realm, so for a character of Pyrewood Village in a guild homed on Nethergarde Keep the two ids never matched and the list came back empty. The login broadcast therefore carried no alts, SaveAlts() skipped its non-empty branch, and expanding a member in the guild pane showed nothing - our own line included. That StoreToSetAndList() call is also what minted the duplicate key in the first place: it registers whatever key it is handed, so simply asking for the alts was enough to give an already known guild a second id. Resolving the id instead of building a key stops that too. The caller inside OnPlayerGuildUpdate() passes the id it has just resolved, since it is only written to DataStore_CharacterGuilds a few lines further down and would not be readable yet on a first login. Co-Authored-By: Claude Opus 5 --- DataStore/API/GuildComm.lua | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/DataStore/API/GuildComm.lua b/DataStore/API/GuildComm.lua index 60c6fe5..b054576 100644 --- a/DataStore/API/GuildComm.lua +++ b/DataStore/API/GuildComm.lua @@ -29,14 +29,16 @@ local function GetKey(name, realm, account) return format("%s.%s.%s", account, realm, name) end -local function GetAlts() +local function GetAlts(guildID) + -- guildID = the guild of the current character. Callers that have just resolved it pass it in, + -- the others ask for the one this character is registered under. + -- Do not rebuild a key from ThisRealm here: on connected realms the guild may be homed on another + -- realm of the group, and OnPlayerGuildUpdate() registered it under that one. Rebuilding it would + -- compare our alts against the wrong id, so none of them would ever match, and it would also + -- register a second id in DataStore_GuildIDs for a guild that already has one. + guildID = guildID or addon:GetCharacterGuildID(addon.ThisCharKey) + if not guildID then return end - local guild = GetGuildInfo("player") - if not guild then return end - - local guildKey = GetKey(guild) - local guildID = addon:StoreToSetAndList(DataStore_GuildIDs, guildKey) - local out = {} for k, charID in pairs(DataStore_CharacterIDs.Set) do local account, realm, char = strsplit(".", k) @@ -131,7 +133,8 @@ local function OnPlayerGuildUpdate() DataStore_GuildFactions[guildID] = addon.ThisFaction -- the first time a valid value is found, broadcast to guild, it must happen here for a standard login, but won't work here after a reloadui since this event is not triggered - addon:GuildBroadcast(commPrefix, MSG_ANNOUNCELOGIN, GetAlts()) + -- pass the id we just resolved, it is only written to DataStore_CharacterGuilds below + addon:GuildBroadcast(commPrefix, MSG_ANNOUNCELOGIN, GetAlts(guildID)) AddonFactory:Broadcast("DATASTORE_ANNOUNCELOGIN", currentGuildName) end