view core.lua @ 66:43913e02a1ef

Detect LFR loot as best we can, and bundle it into the same warning given for heroic loot formatted by name only. Less tedious method of bumping data revisions.
author Farmbuyer of US-Kilrogg <farmbuyer@gmail.com>
date Fri, 27 Apr 2012 10:11:56 +0000
parents 69fd720f853e
children c01875b275ca
line wrap: on
line source
local nametag, addon = ...

--[==[
g_loot's numeric indices are loot entries (including titles, separators,
etc); its named indices are:
- forum         saved text from forum markup window, default nil
- attend        saved text from raid attendence window, default nil
- printed.FOO   last loot index formatted into text window FOO, default 0
- raiders       accumulating raid roster data as we see raid members; indexed
                by player name with subtable fields:
-    class      capitalized English codename ("WARRIOR", "DEATHKNIGHT", etc)
-    subgroup   1-8
-    race       English codename ("BloodElf", etc)
-    sex        1 = unknown/error, 2 = male, 3 = female
-    level      can be 0 if player was offline at the time
-    guild      guild name, or missing if unguilded
-    online     1 = online, 2 = offline, 3 = no longer in raid
    [both of these next two fields use time_t values:]
-    join       time player joined the raid (or first time we've seen them)
-    leave      time player left the raid (or time we've left the raid, if
                'online' is not 3)

Common g_loot entry indices:
- kind          time/boss/loot
- hour          0-23, on the *physical instance server*, not the realm server
- minute        0-59, ditto
- stamp         time_t on the local computer
- cols          graphical display data; cleared when logging out

Time specific g_loot indices:
- startday      table with month/day/year/text fields from makedate()
                text is always "dd Month yyyy"

Boss specific g_loot indices:
- bossname      name of boss/encounter;
                may be changed if "snarky boss names" option is enabled
- reason        wipe/kill ("pull" does not generate an entry)
- instance      name of instance, including size and difficulty
- maxsize       max raid size: 5/10/25, presumably also 15 and 40 could show
                up; can be 0 if we're outside an instance and the player
                inside has an older version
- duration      in seconds; may be missing (only present if local)
- raidersnap    copy of g_loot.raiders at the time of the boss event

Loot specific g_loot indices:
- person        recipient
- person_class  class of recipient if available; may be missing;
                will be classID-style (e.g., DEATHKNIGHT)
- itemname      not including square brackets
- id            itemID as number
- itemlink      full clickable link
- itexture      icon path (e.g., Interface\Icons\INV_Misc_Rune_01)
- quality       ITEM_QUALITY_* number
- disposition   offspec/gvault/shard; missing otherwise; can be set from
                the extratext field
- count         e.g., "x3"; missing otherwise; can be set/removed from
                extratext; triggers only for a stack of items, not "the boss
                dropped double axes today"
- variant       1 = heroic item, 2 = LFR item; missing otherwise
- cache_miss    if GetItemInfo failed; SHOULD be missing (changes other fields)
- bcast_from    player's name if received rebroadcast from that player;
                missing otherwise; can be deleted as a result of in-game
                fiddling of loot data
- extratext     text in Note column, including disposition and rebroadcasting
- extratext_byhand    true if text edited by player directly; missing otherwise


Functions arranged like this, with these lables (for jumping to).  As a
rule, member functions with UpperCamelCase names are called directly by
user-facing code, ones with lowercase names are "one step removed", and
names with leading underscores are strictly internal helper functions.
------ Saved variables
------ Constants
------ Addon member data
------ Globals
------ Expiring caches
------ Ace3 framework stuff
------ Event handlers
------ Slash command handler
------ On/off
------ Behind the scenes routines
------ Saved texts
------ Loot histories
------ Player communication

This started off as part of a raid addon package written by somebody else.
After he retired, I began modifying the code.  Eventually I set aside the
entire package and rewrote the loot tracker module from scratch.  Many of the
variable/function naming conventions (sv_*, g_*, and family) stayed across the
rewrite.

Some variables are needlessly initialized to nil just to look uniform.

]==]

------ Saved variables
OuroLootSV       = nil   -- possible copy of g_loot
OuroLootSV_saved = nil   -- table of copies of saved texts, default nil; keys
                         -- are numeric indices of tables, subkeys of those
						 -- are name/forum/attend/date
OuroLootSV_opts  = nil   -- same as option_defaults until changed
                         -- autoshard:  optional name of disenchanting player, default nil
                         -- threshold:  optional loot threshold, default nil
OuroLootSV_hist  = nil
OuroLootSV_log   = {}


------ Constants
local option_defaults = {
	['datarev'] = 18,    -- cheating, this isn't actually an option
	['popup_on_join'] = true,
	['register_slashloot'] = true,
	['scroll_to_bottom'] = true,
	['chatty_on_kill'] = false,
	['no_tracking_wipes'] = false,
	['snarky_boss'] = true,
	['keybinding'] = false,
	['bossmod'] = "DBM",
	['keybinding_text'] = 'CTRL-SHIFT-O',
	['forum'] = {
		['[url] Wowhead'] = '[url=http://www.wowhead.com/?item=$I]$N[/url]$X - $T',
		['[url] MMO/Wowstead'] = '[http://db.mmo-champion.com/i/$I]$X - $T',
		['[item] by name'] = '[item]$N[/item]$X - $T',
		['[item] by ID'] = '[item]$I[/item]$X - $T',
		['Custom...'] = '',
	},
	['forum_current'] = '[item] by name',
	['display_disabled_LODs'] = false,
	['display_bcast_from'] = true,
}
local virgin = "First time loaded?  Hi!  Use the /ouroloot or /loot command"
	.." to show the main display.  You should probably browse the instructions"
	.." if you've never used this before; %s to display the help window.  This"
	.." welcome message will not intrude again."
local newer_warning = "A newer version has been released. You can %s to display"
	.." a download URL for copy-and-pasting. You can %s to ping other raiders"
	.." for their installed versions (same as '/ouroloot ping' or clicking the"
	.." 'Ping!' button on the options panel)."
local qualnames = {
	['gray'] = 0, ['grey'] = 0, ['poor'] = 0, ['trash'] = 0,
	['white'] = 1, ['common'] = 1,
	['green'] = 2, ['uncommon'] = 2,
	['blue'] = 3, ['rare'] = 3,
	['epic'] = 4, ['purple'] = 4,
	['legendary'] = 5, ['orange'] = 5,
	['artifact'] = 6,
	--['heirloom'] = 7,
}
local my_name				= UnitName('player')
local comm_cleanup_ttl		= 4   -- seconds in the cache
local revision_large		= nil -- defaults to 1, possibly changed by revision
local g_LOOT_ITEM_ss, g_LOOT_ITEM_MULTIPLE_sss, g_LOOT_ITEM_SELF_s, g_LOOT_ITEM_SELF_MULTIPLE_ss


------ Addon member data
local flib = LibStub("LibFarmbuyer")
addon.author_debug = flib.author_debug

-- Play cute games with namespaces here just to save typing.  WTB Lua 5.2 PST.
do local _G = _G setfenv (1, addon)

	commrev			= '16'
	revision		= _G.GetAddOnMetadata(nametag,"Version") or "?"  -- "x.yy.z", etc
	ident			= "OuroLoot2"
	identTg			= "OuroLoot2Tg"
	status_text		= nil

	tekdebug		= nil
	if _G.tekDebug then
		local tdframe = _G.tekDebug:GetFrame("Ouro Loot")
		function tekdebug (txt)
			-- tekDebug notices "<name passed to getframe>|r:"
			tdframe:AddMessage('|cff17ff0dOuro Loot|r:'..txt,1,1,1)
		end
	end

	DEBUG_PRINT		= false
	debug = {
		comm = false,
		loot = false,
		flow = false,
		notraid = false,
		cache = false,
		alsolog = false,
	}
	-- This looks ugly, but it factors out the load-time decisions from
	-- the run-time ones.
	if tekdebug then
		function dprint (t,...)
			if DEBUG_PRINT and debug[t] then
				local text = flib.safefprint(tekdebug,"<"..t.."> ",...)
				if debug.alsolog then
					addon:log_with_timestamp(text)
				end
			end
		end
	else
		function dprint (t,...)
			if DEBUG_PRINT and debug[t] then
				local text = flib.safeprint("<"..t.."> ",...)
				if debug.alsolog then
					addon:log_with_timestamp(text)
				end
			end
		end
	end

	if author_debug and tekdebug then
		function pprint (t,...)
			local text = flib.safefprint(tekdebug,"<<"..t..">> ",...)
			if debug.alsolog then
				addon:log_with_timestamp(text)
			end
		end
	else
		pprint = flib.nullfunc
	end

	enabled			= false
	rebroadcast		= false
	display			= nil   -- display frame, when visible
	loot_clean		= nil   -- index of last GUI entry with known-current visual data
	sender_list		= {active={},names={}}   -- this should be reworked
	threshold		= debug.loot and 0 or 3     -- rare by default
	sharder			= nil   -- name of person whose loot is marked as shards

	-- The rest is also used in the GUI:

	popped			= nil   -- non-nil when reminder has been shown, actual value unimportant

	bossmod_registered = nil
	bossmods = {}

	requesting		= nil   -- for prompting for additional rebroadcasters

	-- don't use NUM_ITEM_QUALITIES as the upper bound unless we expect heirlooms to show up
	thresholds = {}
	for i = 0,6 do
		thresholds[i] = _G.ITEM_QUALITY_COLORS[i].hex .. _G["ITEM_QUALITY"..i.."_DESC"] .. "|r"
	end

	_G.setfenv (1, _G)
end

addon = LibStub("AceAddon-3.0"):NewAddon(addon, "Ouro Loot",
                "AceTimer-3.0", "AceComm-3.0", "AceConsole-3.0", "AceEvent-3.0")


------ Globals
local g_loot			= nil
local g_restore_p		= nil
local g_wafer_thin		= nil   -- for prompting for additional rebroadcasters
local g_today			= nil   -- "today" entry in g_loot
local g_boss_signpost	= nil
local opts				= nil

local pairs, ipairs, tinsert, tremove, tostring, tonumber, wipe =
	pairs, ipairs, table.insert, table.remove, tostring, tonumber, table.wipe
local pprint, tabledump = addon.pprint, flib.tabledump
local CopyTable, GetNumRaidMembers = CopyTable, GetNumRaidMembers
-- En masse forward decls of symbols defined inside local blocks
local _register_bossmod, makedate, create_new_cache, _init, _log

-- Try to extract numbers from the .toc "Version" and munge them into an
-- integral form for comparison.  The result doesn't need to be meaningful as
-- long as we can reliably feed two of them to "<" and get useful answers.
--
-- This makes/reinforces an assumption that revision_large of release packages
-- (e.g., 2016001) will always be higher than those of development packages
-- (e.g., 87), due to the tagging system versus subversion file revs.  This
-- is good, as local dev code will never trigger a false positive update
-- warning for other users.
do
	local r = 0
	for d in addon.revision:gmatch("%d+") do
		r = 1000*r + d
	end
	-- If it's a big enough number to obviously be a release, then make
	-- sure it's big enough to overcome many small previous point releases.
	while r > 2000 and r < 2000000 do
		r = 1000*r
	end
	revision_large = math.max(r,1)
end

-- Hypertext support, inspired by DBM broadcast pizza timers
do
	local hypertext_format_str = "|HOuroRaid:%s|h%s[%s]|r|h"

	-- TEXT will automatically be surrounded by brackets
	-- COLOR can be item quality code or a hex string
	function addon.format_hypertext (code, text, color)
		return hypertext_format_str:format (code,
			type(color)=='number' and ITEM_QUALITY_COLORS[color].hex or color,
			text)
	end

	DEFAULT_CHAT_FRAME:HookScript("OnHyperlinkClick", function(self, link, string, mousebutton)
		local ltype, arg = strsplit(":",link)
		if ltype ~= "OuroRaid" then return end
		-- XXX this is crap, redo this as a dispatch table with code at the call site
		if arg == 'openloot' then
			addon:BuildMainDisplay()
		elseif arg == 'popupurl' then
			-- Sadly, this is not generated by the packager, so hardcode it for now.
			-- The 'data' field is handled differently for onshow than for other callbacks.
			StaticPopup_Show("OUROL_URL", --[[text_arg1=]]nil, --[[text_arg2=]]nil,
				--[[data=]][[http://www.curse.com/addons/wow/ouroloot]])
		elseif arg == 'doping' then
			addon:DoPing()
		elseif arg == 'help' then
			addon:BuildMainDisplay('help')
		elseif arg == 'bcaston' then
			if not addon.rebroadcast then
				addon:Activate(nil,true)
			end
			addon:broadcast('bcast_responder')
		elseif arg == 'waferthin' then   -- mint? it's wafer thin!
			g_wafer_thin = true          -- fuck off, I'm full
			addon:broadcast('bcast_denied')   -- remove once tested
		elseif arg == 'reload' then
			addon:BuildMainDisplay('opt')
		end
	end)

	local old = ItemRefTooltip.SetHyperlink
	function ItemRefTooltip:SetHyperlink (link, ...)
		if link:match("^OuroRaid") then return end
		return old (self, link, ...)
	end
end

do
	-- copied here because it's declared local to the calendar ui, thanks blizz ><
	local CALENDAR_FULLDATE_MONTH_NAMES = {
		FULLDATE_MONTH_JANUARY, FULLDATE_MONTH_FEBRUARY, FULLDATE_MONTH_MARCH,
		FULLDATE_MONTH_APRIL, FULLDATE_MONTH_MAY, FULLDATE_MONTH_JUNE,
		FULLDATE_MONTH_JULY, FULLDATE_MONTH_AUGUST, FULLDATE_MONTH_SEPTEMBER,
		FULLDATE_MONTH_OCTOBER, FULLDATE_MONTH_NOVEMBER, FULLDATE_MONTH_DECEMBER,
	}
	-- returns "dd Month yyyy", mm, dd, yyyy
	function makedate()
		Calendar_LoadUI()
		local _, M, D, Y = CalendarGetDate()
		local text = ("%d %s %d"):format(D, CALENDAR_FULLDATE_MONTH_NAMES[M], Y)
		return text, M, D, Y
	end
end

-- Returns an instance name or abbreviation, followed by the raid size
local function instance_tag()
	-- possibly redo this with the new GetRaidDifficulty function
	local name, typeof, diffcode, diffstr, _, perbossheroic, isdynamic = GetInstanceInfo()
	local t, r
	name = addon.instance_abbrev[name] or name
	if typeof == "none" then return name, MAX_RAID_MEMBERS end
	-- diffstr is "5 Player", "10 Player (Heroic)", etc.  ugh.
	if (GetLFGMode()) and (GetLFGModeType() == 'raid') then
		t,r = 'LFR', 25
	elseif diffcode == 1 then
		t,r = (GetNumRaidMembers()>0) and "10",10 or "5",5
	elseif diffcode == 2 then
		t,r = (GetNumRaidMembers()>0) and "25",25 or "5h",5
	elseif diffcode == 3 then
		t,r = "10h", 10
	elseif diffcode == 4 then
		t,r = "25h", 25
	end
	-- dynamic difficulties always return normal "codes"
	if isdynamic and perbossheroic == 1 then
		t = t .. "h"
	end
	pprint("instance_tag final", t, r)
	return name .. "(" .. t .. ")", r
end
addon.instance_tag = instance_tag   -- grumble
addon.latest_instance = nil         -- spelling reminder, assigned elsewhere


------ Expiring caches
--[[
foo = create_new_cache("myfoo",15[,cleanup]) -- ttl
foo:add("blah")
foo:test("blah")   -- returns true
]]
do
	local caches = {}
	local cleanup_group = _G.AnimTimerFrame:CreateAnimationGroup()
	local time = _G.time
	cleanup_group:SetLooping("REPEAT")
	cleanup_group:SetScript("OnLoop", function(cg)
		addon.dprint('cache',"OnLoop firing")
		local now = time()
		local alldone = true
		-- this is ass-ugly
		for name,c in pairs(caches) do
			local fifo = c.fifo
			local active = #fifo > 0
			while (#fifo > 0) and (now - fifo[1].t > c.ttl) do
				addon.dprint('cache', name, "cache removing", fifo[1].t, "<", fifo[1].m, ">")
				tremove(fifo,1)
			end
			if active and #fifo == 0 and c.func then
				addon.dprint('cache', name, "empty, firing cleanup")
				c:func()
			end
			alldone = alldone and (#fifo == 0)
		end
		if alldone then
			addon.dprint('cache',"OnLoop FINISHING animation group")
			cleanup_group:Finish()
		else
			addon.dprint('cache',"OnLoop done, not yet finished")
		end
	end)

	local function _add (cache, x)
		local datum = { t=time(), m=x }
		cache.hash[x] = datum
		tinsert (cache.fifo, datum)
		if not cleanup_group:IsPlaying() then
			addon.dprint('cache', cache.name, "with entry", datum.t, "<", datum.m, "> STARTING animation group")
			cache.cleanup:SetDuration(1)  -- hmmm
			cleanup_group:Play()
		end
	end
	local function _test (cache, x)
		-- FIXME This can return false positives, if called after the onloop
		-- fifo has been removed but before the GC has removed the weak entry.
		-- What to do, what to do...
		return cache.hash[x] ~= nil
	end

	function create_new_cache (name, ttl, on_alldone)
		-- setting OnFinished for cleanup fires at the end of each inner loop,
		-- with no 'requested' argument to distinguish cases.  thus, on_alldone.
		local c = {
			ttl = ttl,
			name = name,
			add = _add,
			test = _test,
			cleanup = cleanup_group:CreateAnimation("Animation"),
			func = on_alldone,
			fifo = {},
			hash = setmetatable({}, {__mode='kv'}),
		}
		c.cleanup:SetOrder(1)
		caches[name] = c
		return c
	end
end


------ Ace3 framework stuff
function addon:OnInitialize()
	_log = OuroLootSV_log

	-- VARIABLES_LOADED has fired by this point; test if we're doing something like
	-- relogging during a raid and already have collected loot data
	g_restore_p = OuroLootSV ~= nil
	self.dprint('flow', "oninit sets restore as", g_restore_p)

	if OuroLootSV_opts == nil then
		OuroLootSV_opts = {}
		self:ScheduleTimer(function(s)
			s:Print(virgin, s.format_hypertext('help',"click here",ITEM_QUALITY_UNCOMMON))
			virgin = nil
		end,10,self)
	else
		virgin = nil
	end
	opts = OuroLootSV_opts
	local stored_datarev = opts.datarev or 14
	for opt,default in pairs(option_defaults) do
		if opts[opt] == nil then
			opts[opt] = default
		end
	end
	opts.datarev = option_defaults.datarev

	-- transition&remove old options
	opts['forum_use_itemid'] = nil
	if opts['forum_format'] then
		opts.forum['Custom...'] = opts['forum_format']
		opts['forum_format'] = nil
	end
	if opts.forum['[url]'] then
		opts.forum['[url] Wowhead'] = opts.forum['[url]']
		opts.forum['[url]'] = nil
		opts.forum['[url] MMO/Wowstead'] = option_defaults.forum['[url] MMO/Wowstead']
		if opts['forum_current'] == '[url]' then
			opts['forum_current'] = '[url] Wowhead'
		end
	end
	option_defaults = nil
	if OuroLootSV then  -- may not be the same as testing g_restore_p soon
		if OuroLootSV.saved then
			OuroLootSV_saved = OuroLootSV.saved; OuroLootSV.saved = nil
		end
		if OuroLootSV.threshold then
			opts.threshold = OuroLootSV.threshold; OuroLootSV.threshold = nil
		end
		if OuroLootSV.autoshard then
			opts.autoshard = OuroLootSV.autoshard; OuroLootSV.autoshard = nil
		end
	end

	-- get item filter table if needed
	if opts.itemfilter == nil then
		opts.itemfilter = addon.default_itemfilter
	end
	addon.default_itemfilter = nil

	self:RegisterChatCommand("ouroloot", "OnSlash")
	if opts.register_slashloot then
		-- NOTA BENE:  do not use /loot in the LoadOn list, ChatTypeInfo gets confused
		-- maybe try to detect if this command is already in use...
		SLASH_ACECONSOLE_OUROLOOT2 = "/loot"
	end

	self.history_all = self.history_all or OuroLootSV_hist or {}
	local r = assert(GetRealmName())
	self.history_all[r] = self:_prep_new_history_category (self.history_all[r], r)
	self.history = self.history_all[r]
	if (not InCombatLockdown()) and OuroLootSV_hist and 
	   (OuroLootSV_hist.HISTFORMAT == nil)  -- restored data but it's older
	then
		-- Big honkin' loop
		for rname,realm in pairs(self.history_all) do
			for pk,player in ipairs(realm) do
				for lk,loot in ipairs(player) do
					if loot.count == "" then
						loot.count = nil
					end
				end
			end
		end
	end
	self.history_all.HISTFORMAT = nil   -- don't keep this in live data
	--OuroLootSV_hist = nil

	-- Handle changes to the stored data format in stages from oldest to newest.
	if OuroLootSV then
		local dirty = false
		local bumpers = {}
		bumpers[14] = function()
			for i,e in ipairs(OuroLootSV) do
				if e.bosskill then
					e.bossname, e.bosskill = e.bosskill, nil
				end
			end
		end

		bumpers[15] = function()
			for i,e in ipairs(OuroLootSV) do
				if e.kind == 'boss' then
					e.maxsize, e.raiderlist, e.raidersnap = 0, nil, {}
				end
			end
			OuroLootSV.raiders = OuroLootSV.raiders or {}
			for name,r in pairs(OuroLootSV.raiders) do
				r.subgroup = 0
			end
		end

		bumpers[16] = function()
			for i,e in ipairs(OuroLootSV) do
				if e.kind == 'boss' then -- brown paper bag bugs
					e.raidersnap = e.raidersnap or {}
					e.maxsize = e.maxsize or 0
				end
			end
		end

		bumpers[17] = function()
			for i,e in ipairs(OuroLootSV) do
				if e.kind == 'loot' and e.is_heroic then
					e.variant, e.is_heroic = 1, nil
					-- Could try detecting any previous LFR loot here, but... gah
				end
			end
		end

		--[===[
		local real = bumpers
		bumpers = newproxy(true)
		local mt = getmetatable(bumpers)
		mt.__index = real
		mt.__gc = function() print"whadda ya know, garbage collection works" end ]===]

		while stored_datarev < opts.datarev do
			self:Printf("Transitioning saved data format to %d...", stored_datarev+1)
			dirty = true
			bumpers[stored_datarev]()
			stored_datarev = stored_datarev + 1
		end
		if dirty then self:Print("Saved data has been massaged into shape.") end
	end

	_init(self)
	self.dprint('flow', "version strings:", revision_large, self.status_text)
	self.OnInitialize = nil   -- free up ALL the things!
end

function addon:OnEnable()
	self:RegisterEvent("PLAYER_LOGOUT")
	self:RegisterEvent("RAID_ROSTER_UPDATE")

	-- Cribbed from Talented.  I like the way jerry thinks: the first argument
	-- can be a format spec for the remainder of the arguments.  (The new
	-- AceConsole:Printf isn't used because we can't specify a prefix without
	-- jumping through ridonkulous hoops.)  The part about overriding :Print
	-- with a version using prefix hyperlinks is my fault.
	--
	-- There is no ITEM_QUALITY_LEGENDARY constant.  Sigh.
	do
		local AC = LibStub("AceConsole-3.0")
		local chat_prefix = self.format_hypertext('openloot',"Ouro Loot",--[[legendary]]5)
		function addon:Print (str, ...)
			if type(str) == 'string' and str:find("%", nil, --[[plainmatch=]]true) then
				return AC:Print (chat_prefix, str:format(...))
			else
				return AC:Print (chat_prefix, str, ...)
			end
		end
	end

	while opts.keybinding do
		if InCombatLockdown() then
			self:Print("Cannot create '%s' as a keybinding while in combat!",
				opts.keybinding_text)
			self:Print("The rest of the addon will continue to work, but you will need to reload out of combat to get the keybinding.  Either type /reload or use the button on %s in the lower right.", self.format_hypertext('reload',"the options tab",ITEM_QUALITY_UNCOMMON))
			break
		end

		KeyBindingFrame_LoadUI()
		local btn = CreateFrame("Button", "OuroLootBindingOpen", nil, "SecureActionButtonTemplate")
		btn:SetAttribute("type", "macro")
		btn:SetAttribute("macrotext", "/ouroloot toggle")
		if SetBindingClick(opts.keybinding_text, "OuroLootBindingOpen") then
			-- a simple SaveBindings(GetCurrentBindingSet()) occasionally fails when GCBS
			-- decides to return neither 1 nor 2 during load, for reasons nobody has ever learned
			local c = GetCurrentBindingSet()
			if c == ACCOUNT_BINDINGS or c == CHARACTER_BINDINGS then
				SaveBindings(c)
			end
		else
			self:Print("Error registering '%s' as a keybinding, check spelling!",
				opts.keybinding_text)
		end
		break
	end

	--[[
	The four loot format patterns of interest, changed into relatively tight
	string match patterns.  Done at enable-time rather than load-time against
	the slim chance that one of the non-US "delocalizers" needs to mess with
	the global patterns before we transform them.
	
	The SELF variants can be replaced with LOOT_ITEM_PUSHED_SELF[_MULTIPLE] to
	trigger on 'receive item' instead, which would detect extracting stuff
	from mail, or s/PUSHED/CREATED/ for things like healthstones and guild
	cauldron flasks.
	]]

	-- LOOT_ITEM = "%s receives loot: %s." --> (.+) receives loot: (.+)%.
	g_LOOT_ITEM_ss = _G.LOOT_ITEM:gsub('%.$','%%.'):gsub('%%s','(.+)')

	-- LOOT_ITEM_MULTIPLE = "%s receives loot: %sx%d." --> (.+) receives loot: (.+)(x%d+)%.
	g_LOOT_ITEM_MULTIPLE_sss = _G.LOOT_ITEM_MULTIPLE:gsub('%.$','%%.'):gsub('%%s','(.+)'):gsub('x%%d','(x%%d+)')

	-- LOOT_ITEM_SELF = "You receive loot: %s." --> You receive loot: (.+)%.
	g_LOOT_ITEM_SELF_s = _G.LOOT_ITEM_SELF:gsub('%.$','%%.'):gsub('%%s','(.+)')

	-- LOOT_ITEM_SELF_MULTIPLE = "You receive loot: %sx%d." --> You receive loot: (.+)(x%d+)%.
	g_LOOT_ITEM_SELF_MULTIPLE_ss = _G.LOOT_ITEM_SELF_MULTIPLE:gsub('%.$','%%.'):gsub('%%s','(.+)'):gsub('x%%d','(x%%d+)')

	--[[
	Stick something in the Blizzard addons options list, where most users
	will probably look these days.  Try to be conservative about needless
	frame creation.
	]]
	local bliz = CreateFrame("Frame")
	bliz.name = "Ouro Loot"
	bliz:SetScript("OnShow", function(_b)
		local button = CreateFrame("Button",nil,_b,"UIPanelButtonTemplate")
		button:SetWidth(150)
		button:SetHeight(22)
		button:SetScript("OnClick", function()
			_G.InterfaceOptionsFrameCancel:Click()
			_G.HideUIPanel(GameMenuFrame)
			addon:OpenMainDisplayToTab"Options"
		end)
		button:SetText('"/ouroloot opt"')
		button:SetPoint("TOPLEFT",20,-20)
		_b:SetScript("OnShow",nil)
	end)
	_G.InterfaceOptions_AddCategory(bliz)

	self:_scan_LOD_modules()

	if self.debug.flow then self:Print"is in control-flow debug mode." end
end
--function addon:OnDisable() end

do
	local prototype = {}
	local function module_OnEnable (plugin)
		if plugin.option_defaults then
			local SVname = 'OuroLoot'..plugin:GetName()..'_opts'
			if not _G[SVname] then
				_G[SVname] = {}
				if type(plugin.OnFirstTime) == 'function' then
					plugin:OnFirstTime()
				end
			end
			plugin.opts = _G[SVname]
			for option,default in pairs(plugin.option_defaults) do
				if plugin.opts[option] == nil then
					plugin.opts[option] = default
				end
			end
			plugin.option_defaults = nil
		end
	end

	-- By default, no plugins.  First plugin to use the special registration
	-- sets up code for any subsequent plugins.
	addon.is_plugin = flib.nullfunc
	local function module_rtg (plugin, text_type, ...)
		local registry = { [text_type]=plugin }
		addon.is_plugin = function(a,t) return registry[t] end
		prototype.register_text_generator = function(p,t,...)
			registry[t] = p
			return addon:register_text_generator(t,...)
		end
		return addon:register_text_generator(text_type,...)
	end

	prototype.OnEnable = module_OnEnable
	prototype.default_OnEnable = module_OnEnable
	prototype.register_text_generator = module_rtg

	addon:SetDefaultModuleLibraries("AceConsole-3.0")
	addon:SetDefaultModulePrototype(prototype)
	-- Fires before the plugin's own OnEnable (inherited or otherwise).
	--function addon:OnModuleCreated (plugin)
	--	print("created plugin", plugin:GetName())
	--end

	local olrev = tonumber("@project-revision@") or 0
	local err = [[Module '%s' cannot register itself because it failed a required condition: '%s']]
	function addon:ConstrainedNewModule (modname, minrev, mincomm, mindata)
		if not addon.author_debug then
			if minrev and minrev > olrev then
				self:Print(err,modname,
					"revision "..olrev.." older than minimum "..minrev)
				return false
			end
			if mincomm and mincomm > tonumber(self.commrev) then
				self:Print(err,modname,
					"commrev "..self.commrev.." older than minimum "..mincomm)
				return false
			end
			if mindata and mindata > opts.datarev then
				self:Print(err,modname,
					"datarev "..opts.datarev.." older than minimum "..mindata)
				return false
			end
		end
		return self:NewModule(modname)
	end
end


------ Event handlers
function addon:_clear_SVs()
	g_loot = {}  -- not saved, just fooling PLAYER_LOGOUT tests
	OuroLootSV = nil
	OuroLootSV_saved = nil
	OuroLootSV_opts = nil
	OuroLootSV_hist = nil
	OuroLootSV_log = nil
	ReloadUI()
end
function addon:PLAYER_LOGOUT()
	self:UnregisterEvent("RAID_ROSTER_UPDATE")
	self:UnregisterEvent("PLAYER_ENTERING_WORLD")

	local worth_saving = #g_loot > 0 or next(g_loot.raiders)
	if not worth_saving then for text in self:registered_textgen_iter() do
		worth_saving = worth_saving or g_loot.printed[text] > 0
	end end
	if worth_saving then
		opts.autoshard = self.sharder
		opts.threshold = self.threshold
		for i,e in ipairs(g_loot) do
			e.cols = nil
		end
		OuroLootSV = g_loot
	else
		OuroLootSV = nil
	end

	worth_saving = false
	for r,t in pairs(self.history_all) do if type(t) == 'table' then
		if #t == 0 then
			self.history_all[r] = nil
		else
			worth_saving = true
			t.realm = nil
			t.st = nil
			t.byname = nil
		end
	end end
	if worth_saving then
		OuroLootSV_hist = self.history_all
		OuroLootSV_hist.HISTFORMAT = 2
	else
		OuroLootSV_hist = nil
	end
	OuroLootSV_log = #OuroLootSV_log > 0 and OuroLootSV_log or nil
end

do
	local IsInInstance, UnitName, UnitIsConnected, UnitClass, UnitRace, UnitSex,
				UnitLevel, UnitInRaid, UnitIsVisible, GetGuildInfo, GetRaidRosterInfo =
	      IsInInstance, UnitName, UnitIsConnected, UnitClass, UnitRace, UnitSex,
		  		UnitLevel, UnitInRaid, UnitIsVisible, GetGuildInfo, GetRaidRosterInfo
	local time, difftime = time, difftime
	local R_ACTIVE, R_OFFLINE, R_LEFT = 1, 2, 3

	local lastevent, now = 0, 0
	local redo_count = 0
	local redo, timer_handle

	function addon:CheckRoster (leaving_p, now_a)
		if not g_loot.raiders then return end -- bad transition

		now = now_a or time()

		if leaving_p then
			if timer_handle then
				self:CancelTimer(timer_handle)
				timer_handle = nil
			end
			for name,r in pairs(g_loot.raiders) do
				r.leave = r.leave or now
			end
			return
		end

		for name,r in pairs(g_loot.raiders) do
			if r.online ~= R_LEFT and not UnitInRaid(name) then
				r.online = R_LEFT
				r.leave = now
			end
		end

		if redo then
			redo_count = redo_count + 1
		end
		redo = false
		for i = 1, GetNumRaidMembers() do
			local unit = 'raid'..i
			local name = UnitName(unit)
			-- No, that's not my typo, it really is "uknownbeing" in Blizzard's code.
			if name and name ~= UNKNOWN and name ~= UNKNOWNOBJECT and name ~= UKNOWNBEING then
				if not g_loot.raiders[name] then
					g_loot.raiders[name] = { needinfo=true }
				end
				local r = g_loot.raiders[name]
				-- We grab a bunch of return values here, but only pay attention to
				-- them under specific circumstances.
				local grri_name, connected, subgroup, level, class, _
				grri_name, _, subgroup, level, _, class, connected = GetRaidRosterInfo(i)
				if name ~= grri_name then
					error("UnitName ("..tostring(name)..") =/= grri_name ("..
					      tostring(grri_name)..") of same raidindex ("..i..")")
				end
				r.subgroup = subgroup
				if r.needinfo and UnitIsVisible(unit) then
					r.needinfo = nil
					r.class    = class    --select(2,UnitClass(unit))
					r.race     = select(2,UnitRace(unit))
					r.sex      = UnitSex(unit)
					r.level    = level    --UnitLevel(unit)
					r.guild    = GetGuildInfo(unit)
				end
				--local connected = UnitIsConnected(unit)
				if connected and r.online ~= R_ACTIVE then
					r.join = r.join or now
					r.online = R_ACTIVE
				elseif (not connected) and r.online ~= R_OFFLINE then
					r.leave = now
					r.online = R_OFFLINE
				end
				redo = redo or r.needinfo
			end
		end
		if redo then  -- XXX test redo_count here and eventually give up?
			if not timer_handle then
				timer_handle = self:ScheduleRepeatingTimer("RAID_ROSTER_UPDATE", 60)
			end
		else
			redo_count = 0
			if timer_handle then
				self:CancelTimer(timer_handle)
				timer_handle = nil
			end
		end
	end

	function addon:RAID_ROSTER_UPDATE (event)
		if GetNumRaidMembers() == 0 then
			-- Leaving a raid group.
			-- Because of PLAYER_ENTERING_WORLD, this code also executes on load
			-- screens while soloing and in regular groups.  Take care.
			self.dprint('flow', "GetNumRaidMembers == 0")
			if self.enabled and not self.debug.notraid then
				self.dprint('flow', "enabled, leaving raid")
				self.popped = nil
				self:Deactivate()  -- self:UnregisterEvent("CHAT_MSG_LOOT")
				self:CheckRoster(--[[leaving raid]]true)
			end
			return
		end

		local inside,whatkind = IsInInstance()
		if inside and (whatkind == "pvp" or whatkind == "arena") then
			self.dprint('flow', "got RRU event but in pvp zone, bailing")
			return
		end

		local docheck = self.enabled
		if event == "Activate" then
			-- dispatched manually from Activate
			self:RegisterEvent("CHAT_MSG_LOOT")
			_register_bossmod(self)
			docheck = true
		elseif event == "RAID_ROSTER_UPDATE" then
			-- hot code path, be careful

			-- event registration from onload, joined a raid, maybe show popup
			self.dprint('flow', "RRU check:", self.popped, opts.popup_on_join)
			if (not self.popped) and opts.popup_on_join then
				self.popped = StaticPopup_Show "OUROL_REMIND"
				self.popped.data = self
				return
			end
		end
		-- Throttle the checks fired by common events.
		if docheck and not InCombatLockdown() then
			now = time()
			if difftime(now,lastevent) > 45 then
				lastevent = now
				self:CheckRoster(false,now)
			end
		end
	end
end

-- helper for CHAT_MSG_LOOT handler
do
	local function maybe_trash_kill_entry()
		-- this is set on various boss interactions, so we've got a kill/wipe
		-- entry already
		if addon.latest_instance then return end
		addon.latest_instance = instance_tag()
		local ss, max = addon:snapshot_raid()
		addon:_mark_boss_kill (addon._addLootEntry{
			kind='boss', reason='kill', bossname=[[trash]],
			instance=addon.latest_instance, duration=0,
			raidersnap=ss, maxsize=max
		})
	end

	-- Recent loot cache
	local candidates = {}
	local function prefer_local_loots (cache)
		-- The function name is a bit of a misnomer, as local entries overwrite
		-- remote entries as the candidate table is populated.  This routine is
		-- here to extract the final results once the cache timers have expired.
		--
		-- Keep this sync'd with the local_override branch below.
		for i,sig in ipairs(candidates) do
			addon.dprint('loot', "processing candidate entry", i, sig)
			local loot = candidates[sig]
			if loot then
				addon.dprint('loot', i, "was found")
				maybe_trash_kill_entry() -- Generate *some* kind of boss/location entry
				candidates[sig] = nil
				local looti = addon._addLootEntry(loot)
				if (loot.disposition ~= 'shard')
				   and (loot.disposition ~= 'gvault')
				   and (not addon.history_suppress)
				then
					addon:_addHistoryEntry(looti)
				end
			end
		end

		if addon.display then
			addon:redisplay()
		end
		wipe(candidates)
	end
	addon.recent_loot = create_new_cache ('loot', comm_cleanup_ttl+3, prefer_local_loots)

	local GetItemInfo, GetItemIcon = GetItemInfo, GetItemIcon

	-- 'from' and onwards only present if this is triggered by a broadcast
	function addon:_do_loot (local_override, recipient, itemid, count, from, extratext)
		local itexture = GetItemIcon(itemid)
		local iname, ilink, iquality = GetItemInfo(itemid)
		local i
		if (not iname) or (not itexture) then
			i = true
			iname, ilink, iquality, itexture = 
				UNKNOWN..': '..itemid, 'item:6948', ITEM_QUALITY_COMMON, [[ICONS\INV_Misc_QuestionMark]]
		end
		self.dprint('loot',">>_do_loot, R:", recipient, "I:", itemid, "C:", count, "frm:", from, "ex:", extratext, "q:", iquality)

		itemid = tonumber(ilink:match("item:(%d+)") or 0)
		-- This is only a 'while' to make jumping out of it easy and still do cleanup below.
		while local_override or ((iquality >= self.threshold) and not opts.itemfilter[itemid]) do
			if (self.rebroadcast and (not from)) and not local_override then
				self:vbroadcast('loot', recipient, itemid, count)
			end
			if (not self.enabled) and (not local_override) then break end
			local signature = recipient .. iname .. (count or "")
			if from and self.recent_loot:test(signature) then
				self.dprint('cache', "remote loot <",signature,"> already in cache, skipping")
			else
				-- There is some redundancy in all this, in the interests of ease-of-coding
				i = {
					kind		= 'loot',
					person		= recipient,
					person_class= select(2,UnitClass(recipient)),
					cache_miss	= i and true or nil,
					quality		= iquality,
					itemname	= iname,
					id			= itemid,
					itemlink	= ilink,
					itexture	= itexture,
					disposition	= (recipient == self.sharder) and 'shard' or nil,
					count		= (count and count ~= "") and count or nil,
					bcast_from	= from,
					extratext	= extratext,
					variant		= self:is_variant_item(ilink),
				}
				if local_override then
					-- player is adding loot by hand, don't wait for network cache timeouts
					-- keep this sync'd with prefer_local_loots above
					if i.extratext == 'shard'
					   or i.extratext == 'gvault'
					   or i.extratext == 'offspec'
					then
						i.disposition = i.extratext
					end
					local looti = self._addLootEntry(i)
					if (i.disposition ~= 'shard')
					   and (i.disposition ~= 'gvault')
					   and (not self.history_suppress)
					then
						self:_addHistoryEntry(looti)
					end
					i = looti  -- return value mostly for gui's manual entry
				else
					self.recent_loot:add(signature)
					candidates[signature] = i
					tinsert (candidates, signature)
					self.dprint('cache', "loot <",signature,"> added to cache as candidate", #candidates)
				end
			end
			break
		end
		self.dprint('loot',"<<_do_loot out")
		return i
	end

	function addon:CHAT_MSG_LOOT (event, ...)
		if (not self.rebroadcast) and (not self.enabled) and (event ~= "manual") then return end

		--[[
			iname:		Hearthstone
			iquality:	integer
			ilink:		clickable formatted link
			itemstring:	item:6948:....
			itexture:	inventory icon texture
		]]

		if event == "CHAT_MSG_LOOT" then
			local msg = ...
			local person, itemstring, count
			--ChatFrame2:AddMessage("original string:  >"..(msg:gsub("\124","\124\124")).."<")

			-- test in most likely order:  other people get more loot than "you" do
			person, itemstring, count = msg:match(g_LOOT_ITEM_MULTIPLE_sss)
			if not person then
				person, itemstring = msg:match(g_LOOT_ITEM_ss)
			end
			if not person then
				itemstring, count = msg:match(g_LOOT_ITEM_SELF_MULTIPLE_ss)
				if not itemstring then
					itemstring = msg:match(g_LOOT_ITEM_SELF_s)
				end
			end

			self.dprint('loot', "CHAT_MSG_LOOT, person is", person,
				", itemstring is", itemstring, ", count is", count)
			if not itemstring then return end    -- "So-and-So selected Greed", etc, not actual looting

			-- Name might be colorized, remove the highlighting
			if person then
				person = person:match("|c%x%x%x%x%x%x%x%x(%S+)") or person
			else
				person = my_name    -- UNIT_YOU / You
			end

			--local id = tonumber((select(2, strsplit(":", itemstring))))
			local id = tonumber(itemstring:match('|Hitem:(%d+):'))

			return self:_do_loot (false, person, id, count)

		elseif event == "broadcast" then
			return self:_do_loot(false, ...)

		elseif event == "manual" then
			local r,i,n = ...
			return self:_do_loot(true, r,i,nil,nil,n)
		end
	end
end


------ Slash command handler
-- Thought about breaking this up into a table-driven dispatcher.  But
-- that would result in a pile of teensy functions, most of which would
-- never be called.  Too much overhead.  (2.0:  Most of these removed now
-- that GUI is in place.)
function addon:OnSlash (txt) --, editbox)
	txt = strtrim(txt:lower())
	local cmd, arg = ""
	do
		local s,e = txt:find("^%a+")
		if s then
			cmd = txt:sub(s,e)
			s = txt:find("%S", e+2)
			if s then arg = txt:sub(s,-1) end
		end
	end

	if cmd == "" then
		if InCombatLockdown() then
			return self:Print("Shouldn't display window in combat.")
		else
			return self:BuildMainDisplay()
		end

	elseif cmd:find("^thre") then
		self:SetThreshold(arg)

	elseif cmd == "on" then                             self:Activate(arg)
	elseif cmd == "off" then                            self:Deactivate()
	elseif cmd == "broadcast" or cmd == "bcast" then    self:Activate(nil,true)

	elseif cmd == "toggle" then
		if self.display then
			self.display:Hide()
		else
			return self:BuildMainDisplay()
		end

	elseif cmd == "fake" then  -- maybe comment this out for real users
		self:_mark_boss_kill (self._addLootEntry{
			kind='boss',reason='kill',bossname="Baron Steamroller",instance=instance_tag(),duration=0
		})
		self:CHAT_MSG_LOOT ('manual', my_name, 54797)
		if self.display then
			self:redisplay()
		end
		self:Print "Baron Steamroller has been slain.  Congratulations on your rug."

	elseif cmd == "debug" then
		if arg then
			self.debug[arg] = not self.debug[arg]
			_G.print(arg,self.debug[arg])
			if self.debug[arg] then self.DEBUG_PRINT = true end
		else
			self.DEBUG_PRINT = not self.DEBUG_PRINT
		end

	elseif cmd == "save" and arg and arg:len() > 0 then
		self:save_saveas(arg)
	elseif cmd == "list" then
		self:save_list()
	elseif cmd == "restore" and arg and arg:len() > 0 then
		self:save_restore(tonumber(arg))
	elseif cmd == "delete" and arg and arg:len() > 0 then
		self:save_delete(tonumber(arg))

	elseif cmd == "help" then
		self:BuildMainDisplay('help')
	elseif cmd == "ping" then
		self:DoPing()

	elseif cmd == "fixcache" then
		self:do_item_cache_fixup()

	else
		if self:OpenMainDisplayToTab(cmd) then
			return
		end
		self:Print("Unknown command '%s'. %s to see the help window.",
			cmd, self.format_hypertext('help',"Click here",ITEM_QUALITY_UNCOMMON))
	end
end

function addon:SetThreshold (arg, quiet_p)
	local q = tonumber(arg)
	if q then
		q = math.floor(q+0.001)
		if q<0 or q>6 then
			return self:Print("Threshold must be 0-6.")
		end
	else
		q = qualnames[arg]
		if not q then
			return self:Print("Unrecognized item quality argument.")
		end
	end
	self.threshold = q
	if not quiet_p then self:Print("Threshold now set to %s.", self.thresholds[q]) end
end


------ On/off
function addon:Activate (opt_threshold, opt_bcast_only)
	self.dprint('flow', ":Activate is running")
	self:RegisterEvent("RAID_ROSTER_UPDATE")
	self:RegisterEvent("PLAYER_ENTERING_WORLD",
		function() self:ScheduleTimer("RAID_ROSTER_UPDATE", 5, "PLAYER_ENTERING_WORLD") end)
	self.popped = true
	if GetNumRaidMembers() > 0 then
		self.dprint('flow', ">:Activate calling RRU")
		self:RAID_ROSTER_UPDATE("Activate")
	elseif self.debug.notraid then
		self.dprint('flow', ">:Activate registering loot and bossmods")
		self:RegisterEvent("CHAT_MSG_LOOT")
		_register_bossmod(self)
	elseif g_restore_p then
		g_restore_p = nil
		self.popped = nil  -- get the reminder if later joining a raid
		if #g_loot == 0 then
			-- only generated text and raider join/leave data, not worth verbage
			self.dprint('flow', ">:Activate restored generated texts, un-popping")
			return
		end
		self:Print("Ouro Raid Loot restored previous data, but not in a raid",
				"and 5-player mode not active.  |cffff0505NOT tracking loot|r;",
				"use 'enable' to activate loot tracking, or 'clear' to erase",
				"previous data, or 'help' to read about saved-texts commands.")
		return
	end
	self.rebroadcast = true  -- hardcode to true; this used to be more complicated
	self.enabled = not opt_bcast_only
	if opt_threshold then
		self:SetThreshold (opt_threshold, --[[quiet_p=]]true)
	end
	self:Print("Ouro Raid Loot is %s.  Threshold currently %s.",
		self.enabled and "tracking" or "only broadcasting",
		self.thresholds[self.threshold])
	self:broadcast('revcheck',revision_large)
end

-- Note:  running '/loot off' will also avoid the popup reminder when
-- joining a raid, but will not change the saved option setting.
function addon:Deactivate()
	self.enabled = false
	self.rebroadcast = false
	self:UnregisterEvent("RAID_ROSTER_UPDATE")
	self:UnregisterEvent("PLAYER_ENTERING_WORLD")
	self:UnregisterEvent("CHAT_MSG_LOOT")
	self:Print("Ouro Raid Loot deactivated.")
end

function addon:Clear(verbose_p)
	local repopup, st
	if self.display then
		-- in the new version, this is likely to always be the case
		repopup = true
		st = self.display:GetUserData("eoiST")
		if not st then
			self.dprint('flow', "Clear: display visible but eoiST not set??")
		end
		self.display:Hide()
	end
	g_restore_p = nil
	OuroLootSV = nil
	self:_reset_timestamps()
	if verbose_p then
		if (OuroLootSV_saved and #OuroLootSV_saved>0) then
			self:Print("Current loot data cleared, %d saved sets remaining.", #OuroLootSV_saved)
		else
			self:Print("Current loot data cleared.")
		end
	end
	_init(self,st)
	if repopup then
		addon:BuildMainDisplay()
	end
end


------ Behind the scenes routines
-- Semi-experimental debugging aid.
do
	-- Putting _log local to here can result in this sequence:
	-- 1)  logging happens, followed by reload or logout/login
	-- 2)  _log points to SV_log
	-- 3)  VARIABLES_LOADED replaces SV_log pointer with restored version
	-- 4)  logging happens to _log table (now with no other references)
	-- 5)  at logout, nothing new has been entered in the table being saved
	local date = _G.date
	function addon:log_with_timestamp (msg)
		tinsert (_log, date('%m:%d %H:%M:%S  ')..msg)
	end
end

-- Check for plugins which haven't already been loaded, and add hooks for
-- them.  Credit to DBM for the approach here.
function addon:_scan_LOD_modules()
	for i = 1, GetNumAddOns() do
		if GetAddOnMetadata (i, "X-OuroLoot-Plugin")
		   and IsAddOnLoadOnDemand(i)
		   and not IsAddOnLoaded(i)
		then
			local folder, _, _, enabled, _, reason = GetAddOnInfo(i)
			if enabled or opts.display_disabled_LODs then
				local tabtitle = GetAddOnMetadata (i, "X-OuroLoot-Plugin")
				self:_gui_add_LOD_tab (tabtitle, folder, i, enabled, reason)
			end
		end
	end
end

-- Adds indices to traverse the tables in a nice sorted order.
do
	local byindex, temp = {}, {}
	local function sort (src, dest)
		for k in pairs(src) do
			temp[#temp+1] = k
		end
		table.sort(temp)
		wipe(dest)
		for i = 1, #temp do
			dest[i] = src[temp[i]]
		end
	end

	function addon.sender_list.sort()
		sort (addon.sender_list.active, byindex)
		wipe(temp)
		addon.sender_list.activeI = #byindex
		sort (addon.sender_list.names, byindex)
		wipe(temp)
	end
	addon.sender_list.namesI = byindex
end

function addon:DoPing()
	self:Print("Give me a ping, Vasili. One ping only, please.")
	self.sender_list.active = {}
	self.sender_list.names = {}
	self:broadcast('ping')
	self:broadcast('revcheck',revision_large)
end

function addon:_check_revision (otherrev)
	self.dprint('comm', "revchecking against", otherrev)
	otherrev = tonumber(otherrev)
	if otherrev == revision_large then
		-- normal case

	elseif otherrev < revision_large then
		self.dprint('comm', "ours is newer, notifying")
		self:broadcast('revcheck',revision_large)

	else
		self.dprint('comm', "ours is older, yammering")
		if newer_warning then
			self:Print(newer_warning,
				self.format_hypertext('popupurl',"click here",ITEM_QUALITY_UNCOMMON),
				self.format_hypertext('doping',"click here",ITEM_QUALITY_UNCOMMON))
			newer_warning = nil
		end
	end
end

-- Generic helpers
-- Returns index and entry at that index, or nil if not found.
function addon._find_next_after (kind, index)
	index = index + 1
	while index <= #g_loot do
		if g_loot[index].kind == kind then
			return index, g_loot[index]
		end
		index = index + 1
	end
end
-- Essentially a _find_next_after('time'-or-'boss'), but if KIND is
-- 'boss', will also stop upon finding a timestamp.  Returns nil if
-- appropriate fencepost is not found.
function addon._find_timeboss_fencepost (kind, index)
	local fencepost
	local closest_time = addon._find_next_after('time',index)
	if kind == 'time' then
		fencepost = closest_time
	elseif kind == 'boss' then
		local closest_boss = addon._find_next_after('boss',index)
		if not closest_boss then
			fencepost = closest_time
		elseif not closest_time then
			fencepost = closest_boss
		else
			fencepost = math.min(closest_time,closest_boss)
		end
	end
	return fencepost
end

-- Iterate through g_loot entries according to the KIND field.  Loop variables
-- are g_loot indices and the corresponding entries (essentially ipairs + some
-- conditionals).
function addon:filtered_loot_iter (filter_kind)
	return self._find_next_after, filter_kind, 0
end

do
	local itt
	local function create()
		local tip, lefts = CreateFrame("GameTooltip"), {}
		for i = 1, 2 do -- scanning idea here also snagged from Talented
			local L,R = tip:CreateFontString(), tip:CreateFontString()
			L:SetFontObject(GameFontNormal)
			R:SetFontObject(GameFontNormal)
			tip:AddFontStrings(L,R)
			lefts[i] = L
		end
		tip.lefts = lefts
		return tip
	end
	function addon:is_variant_item(item)   -- returns number or *nil*
		itt = itt or create()
		itt:SetOwner(UIParent,"ANCHOR_NONE")
		itt:ClearLines()
		itt:SetHyperlink(item)
		local t = itt.lefts[2]:GetText()
		itt:Hide()
		return (t == ITEM_HEROIC and 1)
		       or (t == RAID_FINDER and 2)  -- no ITEM_ for this, apparently
		       or nil
	end
end

-- Called when first loading up, and then also when a 'clear' is being
-- performed.  If SV's are present then g_restore_p will be true.
function _init (self, possible_st)
	self.dprint('flow',"_init running")
	self.loot_clean = nil
	self.hist_clean = nil
	if g_restore_p then
		g_loot = OuroLootSV
		self.popped = #g_loot > 0
		self.dprint('flow', "restoring", #g_loot, "entries")
		self:ScheduleTimer("Activate", 12, opts.threshold)
		-- FIXME printed could be too large if entries were deleted, how much do we care?
		self.sharder = opts.autoshard
	else
		g_loot = { printed = {}, raiders = {} }
	end

	self.threshold = opts.threshold or self.threshold -- in the case of restoring but not tracking
	self:gui_init(g_loot)
	opts.autoshard = nil
	opts.threshold = nil

	if g_restore_p then
		self:zero_printed_fenceposts()                  -- g_loot.printed.* = previous/safe values
	else
		self:zero_printed_fenceposts(0)                 -- g_loot.printed.* = 0
	end
	if possible_st then
		possible_st:SetData(g_loot)
	end

	self.status_text = ("%s communicating as ident %s commrev %s"):format(self.revision,self.ident,self.commrev)
	self:RegisterComm(self.ident)
	self:RegisterComm(self.identTg, "OnCommReceivedNocache")

	if self.author_debug then
		_G.OL = self
		_G.Oloot = g_loot
	end
end

-- Raid roster snapshots
do
	function addon:snapshot_raid (only_inraid_p)
		local ss = CopyTable(g_loot.raiders)
		local instance,maxsize = instance_tag()
		if only_inraid_p then
			for name,info in next, ss do
				if info.online == 3 then
					ss[name] = nil
				end
			end
		end
		return ss, maxsize, instance, time()
	end
end

-- Tie-in with Deadly Boss Mods (or other such addons)
do
	local candidates = {}
	local location
	local function fixup_durations (cache)
		local boss, bossi
		boss = candidates[1]
		if #candidates == 1 then
			-- (1) or (2)
			boss.duration = boss.duration or 0
			addon.dprint('loot', "only one boss candidate")
		else
			-- (3), should only be one 'cast entry and our local entry
			if #candidates ~= 2 then
				-- could get a bunch of 'cast entries on the heels of one another
				-- before the local one ever fires, apparently... sigh
				--addon:Print("<warning> s3 cache has %d entries, does that seem right to you?", #candidates)
			end
			if candidates[2].duration == nil then
				--addon:Print("<warning> s3's second entry is not the local trigger, does that seem right to you?")
			end
			-- try and be generic anyhow
			for i,c in ipairs(candidates) do
				if c.duration then
					boss = c
					addon.dprint('loot', "fixup found boss candidate", i, "duration", c.duration)
					break
				end
			end
		end
		bossi = addon._addLootEntry(boss)
		-- addon.
		bossi = addon._adjustBossOrder (bossi, g_boss_signpost) or bossi
		g_boss_signpost = nil
		addon.latest_instance = boss.instance
		addon.dprint('loot', "added boss entry", bossi)
		if boss.reason == 'kill' then
			addon:_mark_boss_kill (bossi)
			if opts.chatty_on_kill then
				addon:Print("Registered kill for '%s' in %s!", boss.bossname, boss.instance)
			end
		end
		wipe(candidates)
	end
	addon.recent_boss = create_new_cache ('boss', 10, fixup_durations)

	-- Similar to _do_loot, but duration+ parms only present when locally generated.
	local function _do_boss (self, reason, bossname, intag, maxsize, duration)
		self.dprint('loot',">>_do_boss, R:", reason, "B:", bossname,
		            "T:", intag, "MS:", maxsize, "D:", duration)
		if self.rebroadcast and duration then
			self:vbroadcast('boss', reason, bossname, intag, maxsize)
		end
		-- This is only a loop to make jumping out of it easy, and still do cleanup below.
		while self.enabled do
			if reason == 'wipe' and opts.no_tracking_wipes then break end
			bossname = (opts.snarky_boss and self.boss_abbrev[bossname] or bossname) or bossname
			local not_from_local = duration == nil
			local signature = bossname .. reason
			if not_from_local and self.recent_boss:test(signature) then
				self.dprint('cache', "remote boss <",signature,"> already in cache, skipping")
			else
				self.recent_boss:add(signature)
				g_boss_signpost = #g_loot + 1
				self.dprint('loot', "added boss signpost", g_boss_signpost)
				-- Possible scenarios:  (1) we don't see a boss event at all (e.g., we're
				-- outside the instance) and so this only happens once as a non-local event,
				-- (2) we see a local event first and all non-local events are filtered
				-- by the cache, (3) we happen to get some non-local events before doing
				-- our local event (not because of network weirdness but because our local
				-- DBM might not trigger for a while).
				local c = {
					kind		= 'boss',
					bossname	= bossname,
					reason		= reason,
					instance	= intag,
					duration	= duration,      -- deliberately may be nil
					raidersnap	= self:snapshot_raid(),
					maxsize		= maxsize,
				}
				tinsert(candidates,c)
			end
			break
		end
		self.dprint('loot',"<<_do_boss out")
	end
	-- This exposes the function to OCR, and can be a wrapper layer later.
	addon.on_boss_broadcast = _do_boss

	function addon:_mark_boss_kill (index)
		local e = g_loot[index]
		if not e then
			self:Print("Something horribly wrong;", index, "is not a valid entry!")
			return
		end
		if not e.bossname then
			self:Print("Something horribly wrong;", index, "is not a boss entry!")
			return
		end
		if e.reason ~= 'wipe' then
			-- enh, bail
			self.loot_clean = index-1
		end
		local attempts = 1
		local first

		local i,d = 1,g_loot[1]
		while d ~= e do
			if d.bossname and
			   d.bossname == e.bossname and
			   d.instance == e.instance and
			   d.reason == 'wipe'
			then
				first = first or i
				attempts = attempts + 1
				assert(tremove(g_loot,i)==d,"_mark_boss_kill screwed up data badly")
			else
				i = i + 1
			end
			d = g_loot[i]
		end
		e.reason = 'kill'
		e.attempts = attempts
		self.loot_clean = first or index-1
	end

	function addon:register_boss_mod (name, registration_func, deregistration_func)
		assert(type(name)=='string')
		assert(type(registration_func)=='function')
		if deregistration_func ~= nil then assert(type(deregistration_func)=='function') end
		self.bossmods[#self.bossmods+1] = {
			n = name,
			r = registration_func,
			d = deregistration_func,
		}
		self.bossmods[name] = self.bossmods[#self.bossmods]
		assert(self.bossmods[name].n == self.bossmods[#self.bossmods].n)
	end

	function _register_bossmod (self, force_p)
		local x = self.bossmod_registered and self.bossmods[self.bossmod_registered]
		if x then
			if x.n == opts.bossmod and not force_p then
				-- trying to register with already-registered boss mod
				return
			else
				-- deregister
				if x.d then x.d(self) end
			end
		end

		x = nil
		for k,v in ipairs(self.bossmods) do
			if v.n == opts.bossmod then
				x = k
				break
			end
		end

		if not x then
			self.status_text = "|cffff1010No boss-mod found!|r"
			self:Print(self.status_text)
			return
		end

		if self.bossmods[x].r (self, _do_boss) then
			self.bossmod_registered = self.bossmods[x].n
		else
			self:Print("|cffff1010Boss mod registration failed|r")
		end
	end
end

-- Adding entries to the loot record, and tracking the corresponding timestamp.
do
	-- This shouldn't be required.  /sadface
	local loot_entry_mt = {
		__index = function (e,key)
			if key == 'cols' then
				pprint('mt', e.kind, "key is", key)
				--tabledump(e)  -- not actually that useful
				addon:_fill_out_eoi_data(1)
			end
			return rawget(e,key)
		end
	}

	-- Given a loot index, searches backwards for a timestamp.  Returns that
	-- index and the time entry, or nil if it falls off the beginning.  Pass an
	-- optional second index to search no earlier than that.
	-- May also be able to make good use of this in forum-generation routine.
	function addon:find_previous_time_entry(i,stop)
		stop = stop or 0
		while i > stop do
			if g_loot[i].kind == 'time' then
				return i, g_loot[i]
			end
			i = i - 1
		end
	end

	-- format_timestamp (["format_string"], Day, [Loot])
	-- DAY is a loot entry with kind=='time', and controls the date printed.
	-- LOOT may be any kind of entry in the g_loot table.  If present, it
	--    overrides the hour and minute printed; if absent, those values are
	--    taken from the DAY entry.
	-- FORMAT_STRING may contain $x (x in Y/M/D/h/m) tokens.
	local format_timestamp_values, point2dee = {}, "%.2d"
	function addon:format_timestamp (fmt_opt, day_entry, time_entry_opt)
		if not time_entry_opt then
			if type(fmt_opt) == 'table' then        -- Two entries, default format
				time_entry_opt, day_entry = day_entry, fmt_opt
				fmt_opt = "$Y/$M/$D $h:$m"
			--elseif type(fmt_opt) == "string" then   -- Day entry only, caller-specified format
			end
		end
		--format_timestamp_values.Y = point2dee:format (day_entry.startday.year % 100)
		format_timestamp_values.Y = ("%.4d"):format (day_entry.startday.year)
		format_timestamp_values.M = point2dee:format (day_entry.startday.month)
		format_timestamp_values.D = point2dee:format (day_entry.startday.day)
		format_timestamp_values.h = point2dee:format ((time_entry_opt or day_entry).hour)
		format_timestamp_values.m = point2dee:format ((time_entry_opt or day_entry).minute)
		return fmt_opt:gsub ('%$([YMDhm])', format_timestamp_values)
	end

	local done_todays_date
	function addon:_reset_timestamps()
		done_todays_date = nil
	end
	local function do_todays_date()
		local text, M, D, Y = makedate()
		local found,ts = #g_loot+1
		repeat
			found,ts = addon:find_previous_time_entry(found-1)
			if found and ts.startday.text == text then
				done_todays_date = true
			end
		until done_todays_date or (not found)
		if done_todays_date then
			g_today = ts
		else
			done_todays_date = true
			g_today = g_loot[addon._addLootEntry{
				kind = 'time',
				startday = {
					text = text, month = M, day = D, year = Y
				}
			}]
		end
		addon:_fill_out_eoi_data(1)
	end

	-- Adding anything original to g_loot goes through this routine.
	function addon._addLootEntry (e)
		setmetatable(e,loot_entry_mt)

		if not done_todays_date then do_todays_date() end

		local h, m = GetGameTime()
		--local localuptime = math.floor(GetTime())
		local time_t = time()
		e.hour = h
		e.minute = m
		e.stamp = time_t --localuptime
		local index = #g_loot + 1
		g_loot[index] = e
		return index
	end

	-- Problem:  (1) boss kill happens, (2) fast looting happens, (3) boss
	-- cache cleanup fires.  Result:  loot shows up before boss kill entry.
	-- Solution:  We need to shuffle the boss entry above any of the loot
	-- from that boss.
	function addon._adjustBossOrder (is, should_be)
		--pprint('loot', is, should_be)
		if is == should_be then --pprint('loot', "equal, yay")
			return
		end
		if (type(is)~='number') or (type(should_be)~='number') or (is < should_be) then
			pprint('loot', is, should_be, "...the hell? bailing")
			return
		end
		if g_loot[should_be].kind == 'time' then
			should_be = should_be + 1
			if is == should_be then
				--pprint('loot', "needed to mark day entry, otherwise equal, yay")
				return
			end
		end

		assert(g_loot[is].kind == 'boss')
		local boss = tremove (g_loot, is)
		--pprint('loot', "MOVING", boss.bossname)
		tinsert (g_loot, should_be, boss)
		return should_be
	end
end

-- In the rare case of items getting put into the loot table without current
-- item cache data (which will have arrived by now).
function addon:do_item_cache_fixup()
	self:Print("Fixing up missing item cache data...")

	local numfound = 0
	local borkedpat = '^'..UNKNOWN..': (%S+)'

	for i,e in self:filtered_loot_iter('loot') do
		if e.cache_miss then
			local borked_id = e.itemname:match(borkedpat)
			if borked_id then
				numfound = numfound + 1
				-- Best to use the safest and most flexible API here, which is GII and
				-- its assload of return values.
				local iname, ilink, iquality, _,_,_,_,_,_, itexture = GetItemInfo(borked_id)
				if iname then
					self:Print("    Entry %d patched up with %s.", i, ilink)
					e.quality = iquality
					e.itemname = iname
					e.id = tonumber(ilink:match("item:(%d+)"))
					e.itemlink = ilink
					e.itexture = itexture
					e.cache_miss = nil
				end
			end
		end
	end

	self:Print("...finished.  Found %d |4entry:entries; with weird data.", numfound)
end


------ Saved texts
function addon:check_saved_table(silent_p)
	local s = OuroLootSV_saved
	if s and (#s > 0) then return s end
	OuroLootSV_saved = nil
	if not silent_p then self:Print("There are no saved loot texts.") end
end

function addon:save_list()
	local s = self:check_saved_table(); if not s then return end;
	for i,t in ipairs(s) do
		self:Print("#%d   %s    %d entries     %s", i, t.date, t.count, t.name)
	end
end

function addon:save_saveas(name)
	OuroLootSV_saved = OuroLootSV_saved or {}
	local SV = OuroLootSV_saved
	local n = #SV + 1
	local save = {
		name = name,
		date = makedate(),
		count = #g_loot,
	}
	for text in self:registered_textgen_iter() do
		save[text] = g_loot[text]
	end
	self:Print("Saving current loot texts to #%d '%s'", n, name)
	SV[n] = save
	return self:save_list()
end

function addon:save_restore(num)
	local s = self:check_saved_table(); if not s then return end;
	if (not num) or (num > #s) then
		return self:Print("Saved text number must be 1 - "..#s)
	end
	local save = s[num]
	self:Print("Overwriting current loot data with saved text #%d '%s'", num, save.name)
	self:Clear(--[[verbose_p=]]false)
	-- Clear will already have displayed the window, and re-selected the first
	-- tab.  Set these up for when the text tabs are clicked.
	for text in self:registered_textgen_iter() do
		g_loot[text] = save[text]
	end
end

function addon:save_delete(num)
	local s = self:check_saved_table(); if not s then return end;
	if (not num) or (num > #s) then
		return self:Print("Saved text number must be 1 - "..#s)
	end
	self:Print("Deleting saved text #"..num)
	tremove(s,num)
	return self:save_list()
end


------ Loot histories
-- history_all = {
--   ["Kilrogg"] = {
--     ["realm"] = "Kilrogg",                 -- not saved
--     ["st"] = { lib-st display table },     -- not saved
--     ["byname"] = {                         -- not saved
--       ["OtherPlayer"] = 2,
--       ["Farmbuyer"] = 1,
--     }
--     [1] = {
--       ["name"] = "Farmbuyer",
--       [1] = { id = nnnnn, when = "formatted timestamp for displaying" }  -- most recent loot
--       [2] = { ......., [count = "x3"]                                 }  -- previous loot
--     },
--     [2] = {
--       ["name"] = "OtherPlayer",
--       ......
--     }, ......
--   },
--   ["OtherRealm"] = ......
-- }
do
	local tsort = table.sort
	local comp = function(L,R) return L.when > R.when end

	-- Builds the map of names to array indices, using passed table or
	-- self.history, and stores the result into its 'byname' field.  Also
	-- called from the GUI code at least once.
	function addon:_build_history_names (opt_hist)
		local hist = opt_hist or self.history
		local m = {}
		for i = 1, #hist do
			m[hist[i].name] = i
		end
		hist.byname = m
	end

	-- Prepares and returns table to be used as self.history.
	function addon:_prep_new_history_category (prev_table, realmname)
		local t = prev_table or {
			--kind = 'realm',
			--realm = realmname,
		}
		t.realm = realmname

		--[[
		t.cols = setmetatable({
			{ value = realmname },
		}, self.time_column1_used_mt)
		]]

		if not t.byname then
			self:_build_history_names (t)
		end

		return t
	end

	-- Maps a name to an array index, creating new tables if needed.  Returns
	-- the index and the table at that index.
	function addon:get_loot_history (name)
		local i
		i = self.history.byname[name]
		if not i then
			i = #self.history + 1
			self.history[i] = { name=name }
			self.history.byname[name] = i
		end
		return i, self.history[i]
	end

	function addon:_addHistoryEntry (lootindex)
		local e = g_loot[lootindex]
		if e.kind ~= 'loot' then return end

		local i,h = self:get_loot_history(e.person)
		-- If any of these change, update the end of history_handle_disposition.
		local n = {
			id = e.id,
			when = self:format_timestamp (g_today, e),
			count = e.count,
		}
		tinsert (h, 1, n)
		e.history_unique = n.id .. ' ' .. n.when
	end

	-- Create new history table based on current loot.
	function addon:rewrite_history (realmname)
		local r = assert(realmname)
		self.history_all[r] = self:_prep_new_history_category (nil, r)
		self.history = self.history_all[r]

		local g_today_real = g_today
		for i,e in ipairs(g_loot) do
			if e.kind == 'time' then
				g_today = e
			elseif e.kind == 'loot' then
				self:_addHistoryEntry(i)
			end
		end
		g_today = g_today_real
		self.hist_clean = nil

		-- safety measure:  resort players' tables based on formatted timestamp
		for i,h in ipairs(self.history) do
			tsort (h, comp)
		end
	end

	-- Clears all but latest entry for each player.
	function addon:preen_history (realmname)
		local r = assert(realmname)
		for i,h in ipairs(self.history) do
			tsort (h, comp)
			while #h > 1 do
				tremove (h)
			end
		end
	end

	-- Given an entry in a g_loot table, looks up the corresponding history
	-- entry.  Returns the player's index and history table (as in get_loot_history)
	-- and the index into that table of the loot entry.  On failure, returns nil
	-- and an error message ready to be formatted with the loot's name/itemlink.
	function addon:_history_by_loot_id (loot, operation_text)
		-- Using assert() here would be concatenating error strings that probably
		-- wouldn't be used.  Do more verbose testing instead.
		if type(loot) ~= 'table' then
			error("trying to "..operation_text.." nonexistant entry")
		end
		if loot.kind ~= 'loot' then
			error("trying to "..operation_text.." something that isn't loot")
		end

		local player = loot.person
		local tag = loot.history_unique
		local errtxt
		local player_i, player_h, hist_i

		if not tag then
			errtxt = "Entry for %s is missing a history tag!"
		else
			player_i,player_h = self:get_loot_history(player)
			for i,h in ipairs(player_h) do
				local unique = h.id .. ' ' .. h.when
				if unique == tag then
					hist_i = i
					break
				end
			end
			if not hist_i then
				-- 1) loot an item, 2) clear old history, 3) reassign from current loot
				-- Bah.  Anybody that tricky is already recoding the tables directly anyhow.
				errtxt = "There is no record of %s ever having been assigned!"
			end
		end

		if errtxt then
			return nil, errtxt
		end
		return player_i, player_h, hist_i
	end

	function addon:reassign_loot (index, to_name)
		assert(type(to_name)=='string' and to_name:len()>0)
		local e = g_loot[index]
		local from_i, from_h, hist_i = self:_history_by_loot_id (e, "reassign")
		local from_name = e.person
		local to_i,to_h = self:get_loot_history(to_name)

		if not from_i then
			-- from_h is the formatted error text
			self:Print(from_h .. "  Loot will be reassigned, but history will NOT be updated.", e.itemlink)
		else
			local hist_h = tremove (from_h, hist_i)
			tinsert (to_h, 1, hist_h)
			tsort (from_h, comp)
			tsort (to_h, comp)
		end
		e.person = to_name
		e.person_class = select(2,UnitClass(to_name))
		self.hist_clean = nil

		self:Print("Reassigned entry %d/%s from '%s' to '%s'.", index, e.itemlink, from_name, to_name)
	end

	-- Similar to _addHistoryEntry.  The second arg may be a loot entry
	-- (which used to be at LOOTINDEX), or nil (and the loot entry will
	-- be pulled from LOOTINDEX instead).
	function addon:_delHistoryEntry (lootindex, opt_e)
		local e = opt_e or g_loot[lootindex]
		if e.kind ~= 'loot' then return end

		local from_i, from_h, hist_i = self:_history_by_loot_id (e, "delete")
		if not from_i then
			-- from_h is the formatted error text
			self:Print(from_h .. "  Loot will be deleted, but history will NOT be updated.", e.itemlink)
			return
		end

		--[[local hist_h = ]]tremove (from_h, hist_i)
		tsort (from_h, comp)
		self.hist_clean = nil

		self:Print("Removed history entry %d/%s from '%s'.", lootindex, e.itemlink, e.person)
	end

	-- Any extra work for the "Mark as <x>" dropdown actions.  The
	-- corresponding <x> will already have been assigned in the loot entry.
	local deleted_cache = {} --setmetatable({}, {__mode='k'})
	function addon:history_handle_disposition (index, olddisp)
		local e = g_loot[index]
		-- Standard disposition has a nil entry, but that's tedious in debug
		-- output, so force to a string instead.
		olddisp = olddisp or 'normal'
		local newdisp = e.disposition or 'normal'
		-- Ignore misclicks and the like
		if olddisp == newdisp then return end

		local name = e.person

		if (newdisp == 'shard' or newdisp == 'gvault') then
			local name_i, name_h, hist_i = self:_history_by_loot_id (e, "mark")
			-- remove history entry
			if hist_i then
				local hist_h = tremove (name_h, hist_i)
				deleted_cache[e.history_unique] = hist_h
				self.hist_clean = nil
			elseif (olddisp == 'shard' or olddisp == 'gvault') then
				-- Sharding a vault item, or giving the auto-sharder something to bank,
				-- etc, wouldn't necessarily have had a history entry to begin with.
			else
				self:Print(name_h .. "  Loot has been marked, but history will NOT be updated.", e.itemlink)
			end
			return
		end

		if (olddisp == 'shard' or olddisp == 'gvault')
		   and (newdisp == 'normal' or newdisp == 'offspec')
		then
			local name_i, name_h = self:get_loot_history(name)

			-- Must create a new history entry.  Could call '_addHistoryEntry(index)'
			-- but that would duplicate a lot of effort.  To start with, check the
			-- cache of stuff we've already deleted; if it's not there then just do
			-- the same steps as _addHistoryEntry.
			local entry
			if e.history_unique and deleted_cache[e.history_unique] then
				entry = deleted_cache[e.history_unique]
				deleted_cache[e.history_unique] = nil
			end
			local when = g_today and self:format_timestamp (g_today, e) or tostring(e.stamp)
			entry = entry or {
				id = e.id,
				when = when,
				count = e.count,
			}
			tinsert (name_h, 1, entry)
			e.history_unique = e.history_unique or (entry.id .. ' ' .. entry.when)
			self.hist_clean = nil
			return
		end
	end
end


------ Player communication
do
	local select, tconcat, strsplit = select, table.concat, strsplit
	--[[ old way:  repeated string concatenations, BAD
		 new way:  new table on every call, BAD
	local msg = ...
	for i = 2, select('#',...) do
		msg = msg .. '\a' .. (select(i,...) or "")
	end
	return msg
	]]
	local function assemble(t,...)
		if select('#',...) > 0 then
			local msg = {t,...}
			-- tconcat requires strings, but T is known to be one already
			for i = 2, #msg do
				msg[i] = tostring(msg[i]) or ""
			end
			return tconcat (msg, '\a')
		end
		return t
	end

	-- broadcast('tag', <stuff>)
	-- vbroadcast('tag', <stuff>)
	function addon:vbroadcast(tag,...)
		return self:broadcast(self.commrev..tag,...)
	end
	function addon:broadcast(tag,...)
		local msg = assemble(tag,...)
		self.dprint('comm', "<broadcast>:", msg)
		-- the "GUILD" here is just so that we can also pick up on it
		self:SendCommMessage(self.ident, msg, self.debug.comm and "GUILD" or "RAID")
	end
	-- whispercast(<to>, 'tag', <stuff>)
	function addon:whispercast(to,...)
		local msg = assemble(...)
		self.dprint('comm', "<whispercast>@", to, ":", msg)
		self:SendCommMessage(self.identTg, msg, "WHISPER", to)
	end

	local function adduser (name, status, active)
		if status then addon.sender_list.names[name] = status end
		if active then addon.sender_list.active[name] = active end
	end

	-- Incoming handler functions.  All take the sender name and the incoming
	-- tag as the first two arguments.  All of these are active even when the
	-- player is not tracking loot, so test for that when appropriate.
	local OCR_funcs = {}

	OCR_funcs.ping = function (sender)
		pprint('comm', "incoming ping from", sender)
		addon:whispercast (sender, 'pong', addon.revision, 
			addon.enabled and "tracking" or (addon.rebroadcast and "broadcasting" or "disabled"))
	end
	OCR_funcs.pong = function (sender, _, rev, status)
		local s = ("|cff00ff00%s|r %s is |cff00ffff%s|r"):format(sender,rev,status)
		addon:Print("Echo: ", s)
		adduser (sender, s, status=="tracking" or status=="broadcasting" or nil)
	end
	OCR_funcs.revcheck = function (sender, _, revlarge)
		addon.dprint('comm', "revcheck, sender", sender)
		addon:_check_revision (revlarge)
	end

	OCR_funcs.loot = function (sender, _, recip, item, count, extratext)
		addon.dprint('comm', "DOTloot, sender", sender, "recip", recip, "item", item, "count", count)
		if not addon.enabled then return end
		adduser (sender, nil, true)
		addon:CHAT_MSG_LOOT ("broadcast", recip, item, count, sender, extratext)
	end
	OCR_funcs['16loot'] = OCR_funcs.loot

	OCR_funcs.boss = function (sender, _, reason, bossname, instancetag)
		addon.dprint('comm', "DOTboss, sender", sender, "reason", reason,
			"name", bossname, "it", instancetag)
		if not addon.enabled then return end
		adduser (sender, nil, true)
		addon:on_boss_broadcast (reason, bossname, instancetag, --[[maxsize=]]0)
	end
	OCR_funcs['16boss'] = function (sender, _, reason, bossname, instancetag, maxsize)
		addon.dprint('comm', "DOTboss16, sender", sender, "reason", reason,
			"name", bossname, "it", instancetag, "size", maxsize)
		if not addon.enabled then return end
		adduser (sender, nil, true)
		addon:on_boss_broadcast (reason, bossname, instancetag, maxsize)
	end

	OCR_funcs.bcast_req = function (sender)
		if addon.debug.comm or ((not g_wafer_thin) and (not addon.rebroadcast))
		then
			addon:Print("%s has requested additional broadcasters! Choose %s to enable rebroadcasting, or %s to remain off and also ignore rebroadcast requests for as long as you're logged in.",
				sender,
				addon.format_hypertext('bcaston',"the red pill",'|cffff4040'),
				addon.format_hypertext('waferthin',"the blue pill",'|cff0070dd'))
		end
		addon.popped = true
	end

	OCR_funcs.bcast_responder = function (sender)
		if addon.debug.comm or addon.requesting or
		   ((not g_wafer_thin) and (not addon.rebroadcast))
	   then
			addon:Print(sender, "has answered the call and is now broadcasting loot.")
		end
	end
	-- remove this tag once it's all tested
	OCR_funcs.bcast_denied = function (sender)
		if addon.requesting then addon:Print(sender, "declines futher broadcast requests.") end
	end

	-- Incoming message dispatcher
	local function dotdotdot (sender, tag, ...)
		local f = OCR_funcs[tag]
		addon.dprint('comm', ":... processing",tag,"from",sender)
		if f then return f(sender,tag,...) end
		addon.dprint('comm', "unknown comm message",tag",from", sender)
	end
	-- Recent message cache
	addon.recent_messages = create_new_cache ('comm', comm_cleanup_ttl)

	function addon:OnCommReceived (prefix, msg, distribution, sender)
		if prefix ~= self.ident then return end
		if not self.debug.comm then
			if distribution ~= "RAID" and distribution ~= "WHISPER" then return end
			if sender == my_name then return end
		end
		self.dprint('comm', ":OCR from", sender, "message is", msg)

		if self.recent_messages:test(msg) then
			self.dprint('cache', "OCR message <",msg,"> already in cache, skipping")
			return
		end
		self.recent_messages:add(msg)

		-- Nothing is actually returned, just (ab)using tail calls.
		return dotdotdot(sender,strsplit('\a',msg))
	end

	function addon:OnCommReceivedNocache (prefix, msg, distribution, sender)
		if prefix ~= self.identTg then return end
		if not self.debug.comm then
			if distribution ~= "WHISPER" then return end
			if sender == my_name then return end
		end
		self.dprint('comm', ":OCRN from", sender, "message is", msg)
		return dotdotdot(sender,strsplit('\a',msg))
	end
end

-- vim:noet