diff core.lua @ 73:32eb24fb2ebf

- This code is not quite ready for prime time. Do not run it yet. - Loot events have associated unique IDs, enabling some new actions over the network. These IDs are preserved as part of realm history. As a result, the stored history format has completely changed (and requires less memory as a bonus). - "Prescan for faster handling" option, default off. - "Mark as <x>" now broadcast to other trackers. Older versions can't receive the message, of course. Future: Broadcast reassigning loot. - New options controlling whether (and where) to print a message when another player broadcasts those kinds of changes to existing loot. - Names colored by class when that data is available; CUSTOM_CLASS_COLORS supported. - Metric boatloads of minor tweaks and optimizations throughout.
author Farmbuyer of US-Kilrogg <farmbuyer@gmail.com>
date Tue, 29 May 2012 22:50:09 +0000
parents fb330a1fb6e9
children 124da015c4a2
line wrap: on
line diff
--- a/core.lua	Sat May 12 11:08:23 2012 +0000
+++ b/core.lua	Tue May 29 22:50:09 2012 +0000
@@ -92,7 +92,8 @@
 variable/function naming conventions (sv_*, g_*, and family) stayed across the
 rewrite.
 
-Some variables are needlessly initialized to nil just to look uniform.
+Some variables are needlessly initialized to nil just to look uniform and
+serve as a reminder.
 
 ]==]
 
@@ -130,6 +131,9 @@
 	['forum_current'] = '[item] by name',
 	['display_disabled_LODs'] = false,
 	['display_bcast_from'] = true,
+	['precache_history_uniques'] = false,
+	['chatty_on_remote_changes'] = false,
+	['chatty_on_remote_changes_frame'] = 1,
 }
 local virgin = "First time loaded?  Hi!  Use the /ouroloot or /loot command"
 	.." to show the main display.  You should probably browse the instructions"
@@ -139,6 +143,11 @@
 	.." 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 unique_collision = "|cffff1010%s:|r  Item '%s' was carrying unique tag <"
+	..">, but that was already in use!  (New sender was '%s', previous cache "
+	.."entry was <%s/%s>.)  This may require a live human to figure out; the "
+	.."loot in question has not been stored."
+local remote_chatty = "|cff00ff00%s|r changed %d/%s from %s%s|r to %s%s|r"
 local qualnames = {
 	['gray'] = 0, ['grey'] = 0, ['poor'] = 0, ['trash'] = 0,
 	['white'] = 1, ['common'] = 1,
@@ -187,7 +196,7 @@
 		alsolog = false,
 	}
 	-- This looks ugly, but it factors out the load-time decisions from
-	-- the run-time ones.
+	-- the run-time ones.  Args to [dp]print are concatenated with spaces.
 	if tekdebug then
 		function dprint (t,...)
 			if DEBUG_PRINT and debug[t] then
@@ -287,14 +296,27 @@
 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 g_seeing_oldsigs	= nil
+local g_uniques			= nil   -- memoization of unique loot events
 local opts				= nil
 
-local pairs, ipairs, tinsert, tremove, tostring, tonumber, wipe =
-	pairs, ipairs, table.insert, table.remove, tostring, tonumber, table.wipe
+-- for speeding up local loads, not because I think _G will change
+local _G = _G
+local type = _G.type
+local select = _G.select
+local pairs = _G.pairs
+local ipairs = _G.ipairs
+local tinsert = _G.table.insert
+local tremove = _G.table.remove
+local tostring = _G.tostring
+local tonumber = _G.tonumber
+local wipe = _G.table.wipe
+
 local pprint, tabledump = addon.pprint, flib.tabledump
-local CopyTable, GetNumRaidMembers = CopyTable, GetNumRaidMembers
+local CopyTable, GetNumRaidMembers = _G.CopyTable, _G.GetNumRaidMembers
 -- En masse forward decls of symbols defined inside local blocks
 local _register_bossmod, makedate, create_new_cache, _init, _log
+local _history_by_loot_id, _notify_about_remote
 
 -- 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
@@ -321,6 +343,7 @@
 -- Hypertext support, inspired by DBM broadcast pizza timers
 do
 	local hypertext_format_str = "|HOuroRaid:%s|h%s[%s]|r|h"
+	local strsplit = _G.strsplit
 
 	-- TEXT will automatically be surrounded by brackets
 	-- COLOR can be item quality code or a hex string
@@ -405,18 +428,104 @@
 	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
 
+-- Memoizing cache of unique IDs as we generate or search for them.  Keys are
+-- the uniques, values are the following:
+--   'history'      active index into self.history
+--   'history_may'  index into player's uniques list, CAN QUICKLY BE OUTDATED
+--                  and will instantly be wrong after manual insertion
+--   'loot'         active index into g_loot
+-- with all but the history entry optional.  Values of g_uniqes.NOTFOUND
+-- indicate a known missing status.  Use g_uniques:RESET() to wipe the cache
+-- and return to searching mode.
+do
+	local notfound = -1
+	local notfound_ret = { history = notfound }
+	local mt
+
+	-- This can either be its own function or a slightly redundant __index.
+	local function m_probe_only (t, k)
+		return rawget(t,k) or notfound_ret
+	end
+
+	-- Expensive search.
+	local function m_full_search (t, k)
+		local L, H, HU, loot
+		-- Try active loot entries first
+		for i,e in addon:filtered_loot_iter('loot') do
+			if k == e.unique then
+				L,loot = i,e
+				break
+			end
+		end
+		-- If it's active, try looking through that player's history first.
+		if L then
+			local hi,h = addon:get_loot_history (loot.person)
+			for ui,u in ipairs(h.unique) do
+				if k == u then
+					H, HU = hi, ui
+					break
+				end
+			end
+		else
+			-- No luck?  Ugh, may have been reassigned and we're probing from
+			-- older data.  Search the rest of current realm's history.
+			for hi,h in ipairs(addon.history) do
+				for ui,u in ipairs(h.unique) do
+					if k == u then
+						H, HU = hi, ui
+						break
+					end
+				end
+			end
+		end
+		local ret = { loot = L, history = H or notfound, history_may = HU }
+		t[k] = ret
+		return ret
+	end
+
+	local function m_setmode (self, mode)
+		mt.__index = (mode == 'probe') and m_probe_only or
+		             (mode == 'search') and m_full_search or
+					 nil -- maybe error() here?
+	end
+
+	local function m_reset (self)
+		wipe(self)
+		self[''] = notfound_ret  -- special case for receiving older broadcast
+		self.NOTFOUND = notfound
+		self.RESET = m_reset
+		self.SEARCH = m_full_search
+		self.TEST = m_probe_only
+		self.SETMODE = m_setmode
+		mt.__index = m_full_search
+		return self
+	end
+
+	-- If unique keys ever change into objects instead of strings, change
+	-- this into a weakly-keyed table.
+	mt = { __metatable = 'Should be using setmode.' }
+
+	g_uniques = setmetatable (m_reset{}, mt)
+end
+
 
 ------ Expiring caches
 --[[
-foo = create_new_cache("myfoo",15[,cleanup]) -- ttl
-foo:add("blah")
-foo:test("blah")   -- returns true
+cache = create_new_cache ("mycache", 15 [,cleanup])
+cache:add(foo)
+cache:test(foo)   -- returns true
+   ....5 seconds pass
+cache:add(bar)
+   ....10 seconds pass
+cache:test(foo)   -- returns false
+cache:test(bar)   -- returns true
+   ....5 seconds pass
+   ....bar also gone, cleanup() called
 ]]
 do
 	local caches = {}
@@ -431,7 +540,7 @@
 		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
+			while (#fifo > 0) and (now > fifo[1].t) do
 				addon.dprint('cache', name, "cache removing", fifo[1].t, "<", fifo[1].m, ">")
 				tremove(fifo,1)
 			end
@@ -444,13 +553,14 @@
 		if alldone then
 			addon.dprint('cache',"OnLoop FINISHING animation group")
 			cleanup_group:Finish()
+			_G.collectgarbage()
 		else
 			addon.dprint('cache',"OnLoop done, not yet finished")
 		end
 	end)
 
 	local function _add (cache, x)
-		local datum = { t=time(), m=x }
+		local datum = { t=time()+cache.ttl, m=x }
 		cache.hash[x] = datum
 		tinsert (cache.fifo, datum)
 		if not cleanup_group:IsPlaying() then
@@ -490,16 +600,18 @@
 function addon:OnInitialize()
 	if self.author_debug then
 		_G.OL = self
+		_G.g_uniques = g_uniques
 	end
-	_log = OuroLootSV_log
+	_log = _G.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
+	local OuroLootSV = _G.OuroLootSV
 	g_restore_p = OuroLootSV ~= nil
 	self.dprint('flow', "oninit sets restore as", g_restore_p)
 
-	if OuroLootSV_opts == nil then
-		OuroLootSV_opts = {}
+	if _G.OuroLootSV_opts == nil then
+		_G.OuroLootSV_opts = {}
 		self:ScheduleTimer(function(s)
 			s:Print(virgin, s.format_hypertext('help',"click here",ITEM_QUALITY_UNCOMMON))
 			virgin = nil
@@ -507,7 +619,7 @@
 	else
 		virgin = nil
 	end
-	opts = OuroLootSV_opts
+	opts = _G.OuroLootSV_opts
 	local stored_datarev = opts.datarev or 14
 	for opt,default in pairs(option_defaults) do
 		if opts[opt] == nil then
@@ -557,36 +669,42 @@
 	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"
+		_G.SLASH_ACECONSOLE_OUROLOOT2 = "/loot"
 	end
 
-	self.history_all = self.history_all or OuroLootSV_hist or {}
-	local r = self:load_assert (GetRealmName(), "how the freak does GetRealmName() fail?")
+	self.history_all = self.history_all or _G.OuroLootSV_hist or {}
+	local r = self:load_assert (_G.GetRealmName(), "how the freak does GetRealmName() fail?")
 	self.history_all[r] = self:_prep_new_history_category (self.history_all[r], r)
 	self.history = self.history_all[r]
 
 	local histformat = self.history_all.HISTFORMAT
 	self.history_all.HISTFORMAT = nil   -- don't keep this in live data
-	if (not InCombatLockdown()) and OuroLootSV_hist and 
-	   (histformat == nil or histformat < 3) -- restored data but it's older
-	then
-		-- Big honkin' loop
+	if _G.OuroLootSV_hist
+	   and (histformat == nil or histformat < 4)
+	then   -- some big honkin' loops
 		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
-					if not loot.unique then
-						loot.unique = loot.id .. ' ' .. loot.when
+				if histformat == nil or histformat < 3 then
+					for lk,loot in ipairs(player) do
+						if loot.count == "" then
+							loot.count = nil
+						end
+						if not loot.unique then
+							loot.unique = loot.id .. ' ' .. loot.when
+						end
 					end
 				end
+				-- format 3 to format 4 was a major revamp of per-player data
+				self:_uplift_history_format(player,rname)
 			end
 		end
 	end
+	self._uplift_history_format = nil
 	--OuroLootSV_hist = nil
 
 	-- Handle changes to the stored data format in stages from oldest to newest.
+	-- bumpers[X] is responsible for updating from X to X+1.
+	-- (This is turning into a lot of loops over the same table.  Consolidate?)
 	if OuroLootSV then
 		local dirty = false
 		local bumpers = {}
@@ -634,9 +752,15 @@
 			-- not actually running the same loop twice... probably... sigh.
 
 		bumpers[19] = function()
+			local date = _G.date
 			for i,e in ipairs(OuroLootSV) do
-				if e.kind == 'loot' and e.history_unique then
-					e.unique, e.history_unique = e.history_unique, nil
+				if e.kind == 'loot' then
+					if e.history_unique then
+						e.unique, e.history_unique = e.history_unique, nil
+					end
+					if e.unique == nil or #e.unique == 0 then
+						e.unique = e.id .. ' ' .. date("%Y/%m/%d %H:%M",e.stamp)
+					end
 				end
 			end
 		end
@@ -667,12 +791,15 @@
 	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
+	-- Cribbed from Talented.  I like the way jerry thinks: the first string
+	-- argument can be a format spec for the remainder of the arguments.
 	-- AceConsole:Printf isn't used because we can't specify a prefix without
-	-- jumping through ridonkulous hoops.)  The part about overriding :Print
+	-- jumping through ridonkulous hoops.  The part about overriding :Print
 	-- with a version using prefix hyperlinks is my fault.
 	--
+	-- CFPrint added instead of the usual Print testing of the first arg for
+	-- frame-ness, which would slow down all printing and only rarely be useful.
+	--
 	-- There is no ITEM_QUALITY_LEGENDARY constant.  Sigh.
 	do
 		local AC = LibStub("AceConsole-3.0")
@@ -684,6 +811,14 @@
 				return AC:Print (chat_prefix, str, ...)
 			end
 		end
+		function addon:CFPrint (frame, str, ...)
+			assert(type(frame)=='table' and frame.AddMessage)
+			if type(str) == 'string' and str:find("%", nil, --[[plainmatch=]]true) then
+				return AC:Print (frame, chat_prefix, str:format(...))
+			else
+				return AC:Print (frame, chat_prefix, str, ...)
+			end
+		end
 	end
 
 	while opts.keybinding do
@@ -699,8 +834,9 @@
 		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
+			-- 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)
@@ -758,8 +894,15 @@
 	end)
 	_G.InterfaceOptions_AddCategory(bliz)
 
+	-- Maybe load up g_uniques now?
+	if opts.precache_history_uniques then
+		self:_cache_history_uniques()
+	end
+
 	self:_scan_LOD_modules()
 
+	self:_set_remote_change_chatframe (opts.chatty_on_remote_changes_frame, --[[silent_p=]]true)
+
 	if self.debug.flow then self:Print"is in control-flow debug mode." end
 end
 --function addon:OnDisable() end
@@ -837,18 +980,18 @@
 ------ 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()
+	_G.OuroLootSV = nil
+	_G.OuroLootSV_saved = nil
+	_G.OuroLootSV_opts = nil
+	_G.OuroLootSV_hist = nil
+	_G.OuroLootSV_log = nil
+	_G.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)
+	local worth_saving = #g_loot > 0 or _G.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
@@ -858,9 +1001,9 @@
 		for i,e in ipairs(g_loot) do
 			e.cols = nil
 		end
-		OuroLootSV = g_loot
+		_G.OuroLootSV = g_loot
 	else
-		OuroLootSV = nil
+		_G.OuroLootSV = nil
 	end
 
 	worth_saving = false
@@ -875,12 +1018,12 @@
 		end
 	end end
 	if worth_saving then
-		OuroLootSV_hist = self.history_all
-		OuroLootSV_hist.HISTFORMAT = 3
+		_G.OuroLootSV_hist = self.history_all
+		_G.OuroLootSV_hist.HISTFORMAT = 4
 	else
-		OuroLootSV_hist = nil
+		_G.OuroLootSV_hist = nil
 	end
-	OuroLootSV_log = #OuroLootSV_log > 0 and OuroLootSV_log or nil
+	_G.OuroLootSV_log = #_G.OuroLootSV_log > 0 and _G.OuroLootSV_log or nil
 end
 
 do
@@ -1016,11 +1159,36 @@
 	end
 end
 
--- helper for CHAT_MSG_LOOT handler
+--[=[ CHAT_MSG_LOOT handler and its helpers.
+Situations for "unique tag" generation, given N people seeing local loot
+events, M people seeing remote rebroadcasts, and player Z adding manually:
+
++ Local tracking:  All LOCALs should see the same itemstring, thus the same
+  unique ID stripped out of field #9.  LOCALn includes this in the broadcast
+  to REMOTEm.  Tag is a large number, meaningless for clients and players.
+
++ Local broadcasting, remote tracking:  same as local tracking.  Possibly
+  some weirdness if all local versions are significantly older than the remote
+  versions; in this case each REMOTEn will generate their own tags of the form
+  itemID+formatted_date, which will not be "unique" for the next 60 seconds.
+  As long as at least one LOCALn is recent enough to strip and broadcast a
+  proper ID, multiple items of the same visible name will not be "lost".
+
++ Z manually inserts a loot entry:  Z generates a tag, preserved locally.
+  If Z rebrodcasts that entry, all REMOTEs will see it.  Tag is of the form
+  "n" followed by a random number.
+]=]
 do
+	local counter, _do_loot
+	do
+		local count = 0
+		function counter() count = count + 1; return count; end
+	end
+
 	local function maybe_trash_kill_entry()
 		-- this is set on various boss interactions, so we've got a kill/wipe
-		-- entry already
+		-- entry already -- XXX maybe clear it after a delay, so that loot
+		-- from trash after a boss isn't grouped with that boss?
 		if addon.latest_instance then return end
 		--addon.latest_instance = instance_tag()
 		local ss, max, inst = addon:snapshot_raid()
@@ -1032,8 +1200,40 @@
 		})
 	end
 
+	local random = _G.math.random
+	local function many_uniques_handle_it (u, check_p)
+		if u and check_p then
+			-- Check and alert for an existing value.
+			u = tostring(u)
+			if g_uniques[u].history ~= g_uniques.NOTFOUND then
+				return nil, u
+			end
+			addon.dprint('loot',"verified unique tag ("..u..")")
+		else
+			-- Need to *find* an unused value.  For now use a range of
+			-- J*10^4 where J is Jenny's Constant.  Thank you, xkcd.com/1047.
+			repeat
+				u = 'n' .. random(8675309)
+			until g_uniques:TEST(u).history ~= g_uniques.NOTFOUND
+			addon.dprint('loot',"created unique tag", u)
+		end
+		return true, u
+	end
+
 	-- Recent loot cache
 	local candidates = {}
+	local sigmap = {}
+_G.sigmap = sigmap
+	local function preempt_older_signature (oldersig, newersig)
+		local origin = candidates[oldersig] and candidates[oldersig].from
+		if origin and g_seeing_oldsigs[origin] then
+			-- replace entry from older client with this newer one
+			candidates[oldersig] = nil
+			addon.dprint('cache', "preempting signature <", oldersig, "> from", origin)
+		end
+		return false
+	end
+
 	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
@@ -1044,10 +1244,10 @@
 			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)
+				addon.dprint('loot', "entry", i, "was found, added at index", looti)
 				if (loot.disposition ~= 'shard')
 				   and (loot.disposition ~= 'gvault')
 				   and (not addon.history_suppress)
@@ -1061,88 +1261,130 @@
 			addon:redisplay()
 		end
 		wipe(candidates)
+		wipe(sigmap)
 	end
-	addon.recent_loot = create_new_cache ('loot', comm_cleanup_ttl+3, prefer_local_loots)
+	local recent_loot = create_new_cache ('loot', comm_cleanup_ttl+3, prefer_local_loots)
 
-	local GetItemInfo, GetItemIcon, random = GetItemInfo, GetItemIcon, math.random
+	local strsplit, GetItemInfo, GetItemIcon, UnitClass =
+		_G.strsplit, _G.GetItemInfo, _G.GetItemIcon, _G.UnitClass
 
 	-- 'from' only present if this is triggered by a broadcast
-	function addon:_do_loot (local_override, recipient, unique, itemid, count, from, extratext)
+	function _do_loot (self, local_override, recipient, unique, itemid, count, from, extratext)
+		local prefix = "_do_loot[" .. counter() .. "]"
 		local itexture = GetItemIcon(itemid)
 		local iname, ilink, iquality = GetItemInfo(itemid)
-		local i
+		local cache_miss
 		if (not iname) or (not itexture) then
-			i = true
+			cache_miss = true
 			iname, ilink, iquality, itexture = 
 				UNKNOWN..': '..itemid, 'item:6948', ITEM_QUALITY_COMMON, [[ICONS\INV_Misc_QuestionMark]]
 		end
-		self.dprint('loot',">>_do_loot, R:", recipient, "U:", unique, "I:",
+		self.dprint('loot',">>"..prefix, "R:", recipient, "U:", unique, "I:",
 			itemid, "C:", count, "frm:", from, "ex:", extratext, "q:", iquality)
 
 		itemid = tonumber(ilink:match("item:(%d+)") or 0)
-		unique = tostring(unique or random(8675309))  -- also, xkcd.com/1047
-		-- 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
+
+		-- This is only a 'while' to make jumping out of it easy.
+		local i, unique_okay, ret1, ret2
+		while local_override
+		      or ((iquality >= self.threshold) and not opts.itemfilter[itemid])
+		do
+			unique_okay, unique = many_uniques_handle_it (unique, not local_override)
+			if not unique_okay then
+				i = g_uniques[unique]
+				local err = unique_collision:format (ERROR_CAPS, iname, unique,
+					tostring(from), tostring(i.loot), tostring(i.history))
+				self:Print(err)
+				_G.PlaySound("igQuestFailed", "master")
+				-- Make sure this is logged one way or another
+				;(self.debug.loot and self.dprint or pprint)('loot', "COLLISION", prefix, err);
+				ret1, ret2 = nil, err
+				break
+			end
+
 			if (self.rebroadcast and (not from)) and not local_override then
 				self:vbroadcast('loot', recipient, unique, itemid, count)
 			end
 			if (not self.enabled) and (not local_override) then break end
-			local signature = unique .. recipient .. iname .. (count or "")
-			if from and self.recent_loot:test(signature) then
-				self.dprint('cache', "remote loot <",signature,"> already in cache, skipping")
+
+			local oldersig = recipient .. iname .. (count or "")
+			local signature, seenit
+			if #unique > 0 then
+				-- newer case
+				signature = unique .. oldersig
+				sigmap[oldersig] = signature
+				seenit = from and (recent_loot:test(signature)
+					-- The following clause is what handles older 'casts arriving
+					-- earlier.  All this is tested inside-out to maximize short
+					-- circuit avaluation.
+					or (g_seeing_oldsigs and preempt_older_signature(oldersig,signature)))
 			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,
-					unique		= unique,
-					count		= (count and count ~= "") and count or nil,
-					bcast_from	= from,
-					extratext	= extratext,
-					variant		= self:is_variant_item(ilink),
-				}
-				if opts.itemvault[itemid] then
-					i.disposition = 'gvault'
-				elseif recipient == self.sharder then
-					i.disposition = 'shard'
+				-- older case, only remote
+				assert(from)
+				signature = sigmap[oldersig] or oldersig
+				seenit = recent_loot:test(signature)
+			end
+
+			if seenit then
+				self.dprint('cache', "remote", prefix, "<", signature,
+					"> already in cache, skipping from", from)
+				break
+			end
+
+			-- 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	= cache_miss,
+				quality		= iquality,
+				itemname	= iname,
+				id			= itemid,
+				itemlink	= ilink,
+				itexture	= itexture,
+				unique		= unique,
+				count		= (count and count ~= "") and count or nil,
+				bcast_from	= from,
+				extratext	= extratext,
+				variant		= self:is_variant_item(ilink),
+			}
+			if opts.itemvault[itemid] then
+				i.disposition = 'gvault'
+			elseif recipient == self.sharder then
+				i.disposition = 'shard'
+			end
+			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
-				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)
+				local looti = self._addLootEntry(i)
+				if (i.disposition ~= 'shard')
+				   and (i.disposition ~= 'gvault')
+				   and (not self.history_suppress)
+				then
+					self:_addHistoryEntry(looti)
 				end
+				ret1 = looti  -- return value mostly for gui's manual entry
+			else
+				recent_loot:add(signature)
+				candidates[signature] = i
+				tinsert (candidates, signature)
+				self.dprint('cache', prefix, "<", signature,
+					"> added to cache as candidate", #candidates)
 			end
 			break
 		end
-		self.dprint('loot',"<<_do_loot out")
-		return i
+		self.dprint('loot',"<<"..prefix, "out")
+		return ret1, ret2
 	end
 
+	-- Returns the index of the resulting new loot entry, or nil after
+	-- displaying any errors.
 	function addon:CHAT_MSG_LOOT (event, ...)
 		if (not self.rebroadcast) and (not self.enabled) and (event ~= "manual") then return end
 
@@ -1173,7 +1415,7 @@
 
 			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
+			if not itemstring then return end    -- "PlayerX selected Greed", etc, not looting
 
 			-- Name might be colorized, remove the highlighting
 			if person then
@@ -1187,14 +1429,14 @@
 			_,id,_,_,_,_,_,_,unique = strsplit (":", itemstring)
 			if unique == 0 then unique = nil end
 
-			return self:_do_loot (false, person, unique, id, count)
+			return _do_loot (self, false, person, unique, id, count)
 
 		elseif event == "broadcast" then
-			return self:_do_loot(false, ...)
+			return _do_loot(self, false, ...)
 
 		elseif event == "manual" then
 			local r,i,n = ...
-			return self:_do_loot(true, r, --[[unique=]]nil, i,
+			return _do_loot(self, true, r, --[[unique=]]nil, i,
 				--[[count=]]nil, --[[from=]]nil, n)
 		end
 	end
@@ -1332,6 +1574,7 @@
 	end
 	self.rebroadcast = true  -- hardcode to true; this used to be more complicated
 	self.enabled = not opt_bcast_only
+	g_seeing_oldsigs = nil
 	if opt_threshold then
 		self:SetThreshold (opt_threshold, --[[quiet_p=]]true)
 	end
@@ -1364,11 +1607,11 @@
 		self.display:Hide()
 	end
 	g_restore_p = nil
-	OuroLootSV = nil
+	_G.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)
+		if (_G.OuroLootSV_saved and #_G.OuroLootSV_saved>0) then
+			self:Print("Current loot data cleared, %d saved sets remaining.", #_G.OuroLootSV_saved)
 		else
 			self:Print("Current loot data cleared.")
 		end
@@ -1391,7 +1634,7 @@
 	-- 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)
+		tinsert (_log, date('%m/%d %H:%M:%S  ')..msg)
 	end
 end
 
@@ -1412,6 +1655,63 @@
 	end
 end
 
+-- Routines for printing changes made by remote users.
+do
+	local remote_change_chatframe
+
+	function addon:_set_remote_change_chatframe (arg, silent_p)
+		local frame
+		if type(arg) == 'number' then
+			arg = _G.math.min (arg, _G.NUM_CHAT_WINDOWS)
+			frame = _G['ChatFrame'..arg]
+		elseif type(arg) == 'string' then
+			frame = _G[arg]
+		end
+		if type(frame) == 'table' and type(frame.AddMessage) == 'function' then
+			remote_change_chatframe = frame
+			if not silent_p then
+				local msg = "Now printing to chat frame " .. arg
+				if frame.GetName then
+					msg = msg .. " (" .. tostring(frame:GetName()) .. ")"
+				end
+				self:Print(msg)
+				if frame ~= _G.DEFAULT_CHAT_FRAME then
+					self:CFPrint (frame, msg)
+				end
+			end
+			return frame
+		else
+			self:Print("'%s' was not a valid chat frame number/name, no change has been made.", arg)
+		end
+	end
+
+	function _notify_about_remote (sender, index, from_whom, olddisp)
+		local e = g_loot[index]
+		local from_color, from_text, to_color, to_text
+		if from_whom then
+			-- FIXME need to return previous name/class from reassign_loot
+
+		else
+			if olddisp then
+				from_text = addon.disposition_colors[olddisp].text
+			else
+				olddisp = "normal"
+				from_text = "normal"
+			end
+			from_color = addon.disposition_colors[olddisp].hex
+			if e.disposition then
+				to_text = addon.disposition_colors[e.disposition].text
+			else
+				to_text = "normal"
+			end
+			to_color = addon.disposition_colors[e.disposition or "normal"].hex
+		end
+
+		addon:CFPrint (remote_change_chatframe, remote_chatty, sender, index,
+			e.itemlink, from_color, from_text, to_color, to_text)
+	end
+end
+
 -- Adds indices to traverse the tables in a nice sorted order.
 do
 	local byindex, temp = {}, {}
@@ -1419,7 +1719,7 @@
 		for k in pairs(src) do
 			temp[#temp+1] = k
 		end
-		table.sort(temp)
+		_G.table.sort(temp)
 		wipe(dest)
 		for i = 1, #temp do
 			dest[i] = src[temp[i]]
@@ -1491,7 +1791,7 @@
 		elseif not closest_time then
 			fencepost = closest_boss
 		else
-			fencepost = math.min(closest_time,closest_boss)
+			fencepost = _G.math.min(closest_time,closest_boss)
 		end
 	end
 	return fencepost
@@ -1538,7 +1838,7 @@
 	self.loot_clean = nil
 	self.hist_clean = nil
 	if g_restore_p then
-		g_loot = OuroLootSV
+		g_loot = _G.OuroLootSV
 		self.popped = #g_loot > 0
 		self.dprint('flow', "restoring", #g_loot, "entries")
 		self:ScheduleTimer("Activate", 12, opts.threshold)
@@ -1554,15 +1854,16 @@
 	opts.threshold = nil
 
 	if g_restore_p then
-		self:zero_printed_fenceposts()                  -- g_loot.printed.* = previous/safe values
+		self:zero_printed_fenceposts()   -- g_loot.printed.* = previous/safe values
 	else
-		self:zero_printed_fenceposts(0)                 -- g_loot.printed.* = 0
+		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.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")
 
@@ -1577,13 +1878,13 @@
 		local ss = CopyTable(g_loot.raiders)
 		local instance,maxsize = instance_tag()
 		if only_inraid_p then
-			for name,info in next, ss do
+			for name,info in _G.next, ss do
 				if info.online == 3 then
 					ss[name] = nil
 				end
 			end
 		end
-		return ss, maxsize, instance, time()
+		return ss, maxsize, instance, _G.time()
 	end
 end
 
@@ -1631,7 +1932,7 @@
 		end
 		wipe(candidates)
 	end
-	addon.recent_boss = create_new_cache ('boss', 10, fixup_durations)
+	local 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)
@@ -1646,10 +1947,10 @@
 			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
+			if not_from_local and recent_boss:test(signature) then
 				self.dprint('cache', "remote boss <",signature,"> already in cache, skipping")
 			else
-				self.recent_boss:add(signature)
+				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
@@ -1763,6 +2064,8 @@
 
 -- Adding entries to the loot record, and tracking the corresponding timestamp.
 do
+	local rawget, setmetatable = _G.rawget, _G.setmetatable
+
 	-- This shouldn't be required.  /sadface
 	local loot_entry_mt = {
 		__index = function (e,key)
@@ -1846,9 +2149,9 @@
 
 		if not done_todays_date then do_todays_date() end
 
-		local h, m = GetGameTime()
+		local h, m = _G.GetGameTime()
 		--local localuptime = math.floor(GetTime())
-		local time_t = time()
+		local time_t = _G.time()
 		e.hour = h
 		e.minute = m
 		e.stamp = time_t --localuptime
@@ -1911,26 +2214,38 @@
 	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
+	-- 'while true' so that we can use (inner) break as (outer) continue
+	for i,e in self:filtered_loot_iter('loot') do while true do
+		if not e.cache_miss then break end
+		local borked_id = e.itemname:match(borkedpat)
+		if not borked_id then break end
+		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
+			local msg = [[    Entry %d patched up with %s.]]
+			e.quality = iquality
+			e.itemname = iname
+			e.id = tonumber(ilink:match("item:(%d+)"))
+			e.itemlink = ilink
+			e.itexture = itexture
+			e.cache_miss = nil
+			if e.unique then
+				local gu = g_uniques[e.unique]
+				local player_i, player_h, hist_i = _history_by_loot_id (e.unique, "fixcache")
+				if gu.loot ~= i then   -- is this an actual problem?
+					pprint('loot', ("Unique value '%s' had iterator value %d but g_uniques index %s."):format(e.unique,i,tostring(gu.loot)))
+				end
+				if player_i then
+					player_h.id[e.unique] = e.id
+					msg = [[    Entry %d (and history) patched up with %s.]]
 				end
 			end
+			self:Print(msg, i, ilink)
 		end
-	end
+		break
+	end end
 
 	self:Print("...finished.  Found %d |4entry:entries; with weird data.", numfound)
 end
@@ -1938,9 +2253,9 @@
 
 ------ Saved texts
 function addon:check_saved_table(silent_p)
-	local s = OuroLootSV_saved
+	local s = _G.OuroLootSV_saved
 	if s and (#s > 0) then return s end
-	OuroLootSV_saved = nil
+	_G.OuroLootSV_saved = nil
 	if not silent_p then self:Print("There are no saved loot texts.") end
 end
 
@@ -1952,8 +2267,8 @@
 end
 
 function addon:save_saveas(name)
-	OuroLootSV_saved = OuroLootSV_saved or {}
-	local SV = OuroLootSV_saved
+	_G.OuroLootSV_saved = _G.OuroLootSV_saved or {}
+	local SV = _G.OuroLootSV_saved
 	local n = #SV + 1
 	local save = {
 		name = name,
@@ -2005,8 +2320,13 @@
 --     }
 --     [1] = {
 --       ["name"] = "Farmbuyer",
---       [1] = { id = nnnnn, when = "formatted timestamp for displaying" }  -- most recent loot
---       [2] = { ......., [count = "x3"]                                 }  -- previous loot
+--       ["person_class"] = "PRIEST", -- may be missing, used in display only
+--       -- sorted array:
+--       ["unique"] = { most_recent_tag, previous_tag, .... },
+--       -- these are indexed by unique tags, and 'count' may be missing:
+--       ["when"] = { ["tag"] = "formatted timestamp for displaying loot" },
+--       ["id"] = { ["tag"] = 11111 },
+--       ["count"] = { ["tag"] = "x3", .... },
 --     },
 --     [2] = {
 --       ["name"] = "OtherPlayer",
@@ -2015,9 +2335,94 @@
 --   },
 --   ["OtherRealm"] = ......
 -- }
+--
+-- Up through 2.81.4 (specifically through rev 95), an individual player's
+-- table looked like this:
+--       ["name"] = "Farmbuyer",
+--       [1] = { id = nnnnn, when = "formatted timestamp for displaying" }  -- most recent loot
+--       [2] = { ......., [count = "x3"]                                 }  -- previous loot
+-- which was much easier to manipulate, but had a ton of memory overhead.
 do
-	local tsort = table.sort
-	local comp = function(L,R) return L.when > R.when end
+	-- Sorts a player's history from newest to oldest, according to the
+	-- formatted timestamp.  This is expensive, and destructive for P.unique.
+	local function sort_player (p)
+		local new_uniques, uniques_bywhen, when_array = {}, {}, {}
+		for u,tstamp in pairs(p.when) do
+			uniques_bywhen[tstamp] = u
+			when_array[#when_array+1] = tstamp
+		end
+		_G.table.sort(when_array)
+		for i,tstamp in ipairs(when_array) do
+			new_uniques[i] = uniques_bywhen[tstamp]
+		end
+		p.unique = new_uniques
+	end
+
+	-- Possibly called during login.  Cleared when no longer needed.
+	-- Rewrites a PLAYER table from format 3 to format 4.
+	function addon:_uplift_history_format (player, realmname)
+		local unique, when, id, count = {}, {}, {}, {}
+		local name = player.name
+
+		for i,h in ipairs(player) do
+			local U = h.unique
+			unique[i] = U
+			when[U] = h.when
+			id[U] = h.id
+			count[U] = h.count
+		end
+
+		wipe(player)
+		player.name = name
+		player.id, player.when, player.unique, player.count =
+			id, when, unique, count
+	end
+	function addon:_cache_history_uniques()
+		UpdateAddOnMemoryUsage()
+		local before = GetAddOnMemoryUsage(nametag)
+		local trouble
+		local count = 0
+		for hi,player in ipairs(self.history) do
+			for ui,u in ipairs(player.unique) do
+				g_uniques[u] = { history = hi, history_may = ui }
+				count = count + 1
+			end
+		end
+		for i,e in self:filtered_loot_iter('loot') do
+			if e.unique and e.unique ~= "" then
+				local hmmm = _G.rawget(g_uniques,e.unique)
+				if hmmm then
+					hmmm.loot = i --;print("Active loot", i, "found with tag", e.unique)
+				elseif e.disposition == 'shard' or e.disposition == 'gvault' then
+					g_uniques[e.unique] = { loot = i, history = g_uniques.NOTFOUND }
+					count = count + 1
+					--print("Active loot", i, "INSERTED with tag", e.unique, "as", e.disposition)
+				else
+					hmmm = "wonked data ("..i.."/"..tostring(e.unique)..") in precache loop!"
+					pprint(hmmm)
+					-- try to simply fix up errors as we go
+					g_uniques[e.unique] = { loot = i, history = g_uniques.NOTFOUND }
+					trouble = true
+				end
+			else
+				trouble = true
+				pprint('loot', "ERROR precache loop found bad unique tag!",
+					i, "tag", tostring(e.unique), "from?", tostring(e.bcast_from))
+			end
+		end
+		UpdateAddOnMemoryUsage()
+		local after = GetAddOnMemoryUsage(nametag)
+		self:Print("Pre-scanning history for faster loot handling on %s used %.2f MB of memory across %d entries.",
+			self.history.realm, (after-before)/1024, count)
+		if trouble then
+			self:Print("Note that there were inconsistencies in the data;",
+				"you should consider submitting a bug report (including your",
+				"SavedVariables file), and regenerating or preening this",
+				"realm's loot history.")
+		end
+		g_uniques:SETMODE('probe')
+		self._cache_history_uniques = nil
+	end
 
 	-- Builds the map of names to array indices, using passed table or
 	-- self.history, and stores the result into its 'byname' field.  Also
@@ -2028,6 +2433,7 @@
 		for i = 1, #hist do
 			m[hist[i].name] = i
 		end
+		-- why yes, I *did* spend many years as a YP/NIS admin, how did you know?
 		hist.byname = m
 	end
 
@@ -2059,28 +2465,32 @@
 		i = self.history.byname[name]
 		if not i then
 			i = #self.history + 1
-			self.history[i] = { name=name }
+			self.history[i] = { name=name, id={}, when={}, unique={}, count={} }
 			self.history.byname[name] = i
 		end
 		return i, self.history[i]
 	end
 
+	-- Prepends data from the loot entry at LOOTINDEX to be the new most
+	-- recent history entry for that player.
 	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)
+		local when = self:format_timestamp (g_today, e)
+
 		-- 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)
 		if (not e.unique) or (#e.unique==0) then
-			e.unique = n.id .. ' ' .. n.when
+			e.unique = e.id .. ' ' .. when
 		end
-		n.unique = e.unique
+		local U = e.unique
+		tinsert (h.unique, 1, U)
+		h.when[U] = when
+		h.id[U] = e.id
+		h.count[U] = e.count
+
+		g_uniques[U] = { loot = lootindex, history = i }
 	end
 
 	-- Create new history table based on current loot.
@@ -2088,6 +2498,7 @@
 		local r = assert(realmname)
 		self.history_all[r] = self:_prep_new_history_category (nil, r)
 		self.history = self.history_all[r]
+		g_uniques:RESET()
 
 		local g_today_real = g_today
 		for i,e in ipairs(g_loot) do
@@ -2102,81 +2513,105 @@
 
 		-- safety measure:  resort players' tables based on formatted timestamp
 		for i,h in ipairs(self.history) do
-			tsort (h, comp)
+			sort_player(h)
 		end
 	end
 
 	-- Clears all but latest entry for each player.
 	function addon:preen_history (realmname)
 		local r = assert(realmname)
+		g_uniques:RESET()
 		for i,h in ipairs(self.history) do
-			tsort (h, comp)
-			while #h > 1 do
-				tremove (h)
-			end
+			-- This is going to do horrible things to memory.  The subtables
+			-- after this step would be large and sparse, with no good way
+			-- of shrinking the allocation...
+			sort_player(h)
+			-- ...so it's better in the long run to discard them.
+			local U = h.unique[1]
+			h.unique = { U }
+			h.id = { [U] = h.id[U] }
+			h.when = { [U] = h.when[U] }
+			h.count = { [U] = h.count[U] }
 		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")
+	-- Given a unique tag OR 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 _history_by_loot_id (needle, operation_text)
+		local errtxt
+		if type(needle) == 'string' then
+			-- unique tag
+		elseif type(needle) == 'table' then
+			if needle.kind ~= 'loot' then
+				error("trying to "..operation_text.." something that isn't loot")
+			end
+			needle = needle.unique
+			if not needle then
+				return nil, --[[errtxt=]]"Entry for %s is missing a history tag!"
+			end
+		else
+			error("'"..tostring(needle).."' is neither unique string nor loot entry!")
 		end
 
-		local player = loot.person
-		local tag = loot.unique
-		local errtxt
-		local player_i, player_h, hist_i
+		local player_i, player_h
+		local cache = g_uniques[needle]
 
-		if not tag then
-			errtxt = "Entry for %s is missing a history tag!"
+		if cache.history == g_uniques.NOTFOUND 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!"
 		else
-			player_i,player_h = self:get_loot_history(player)
-			for i,h in ipairs(player_h) do
-				if h.unique == tag then
-					hist_i = i
-					break
+			player_i = cache.history
+			player_h = addon.history[player_i]
+			if cache.history_may
+			   and needle == player_h.unique[cache.history_may]
+			then
+				return player_i, player_h, cache.history_may
+			end
+			for i,u in ipairs(player_h.unique) do
+				if needle == u then
+					cache.history_may = i   -- might help, might not
+					return player_i, player_h, i
 				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
+		if not errtxt then
+			-- The cache finder got a hit, but now it's gone?  WTF?
+			errtxt = "ZOMG!  %s was in history but now is gone.  Possibly your history tables have been corrupted and should be recreated.  This is likely a bug.  Tell Farmbuyer what steps you took to cause this."
 		end
-		return player_i, player_h, hist_i
+		return nil, errtxt
 	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_i, from_h, hist_i = _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
+		if not from_i then   -- from_h here 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)
+			local U = tremove (from_h.unique, hist_i)
+			-- The loot master giveth...
+			to_h.unique[#to_h.unique+1] = U
+			to_h.when[U] = from_h.when[U]
+			to_h.id[U] = from_h.id[U]
+			to_h.count[U] = from_h.count[U]
+			sort_player(to_h)
+			-- ...and the loot master taketh away.
+			from_h.when[U] = nil
+			from_h.id[U] = nil
+			from_h.count[U] = nil
+			-- Blessed be the lookup cache of the loot master.
+			g_uniques[U] = { loot = index, history = to_i }
 		end
 		e.person = to_name
-		e.person_class = select(2,UnitClass(to_name))
+		e.person_class = select(2,_G.UnitClass(to_name))
 		self.hist_clean = nil
 
 		self:Print("Reassigned entry %d/%s from '%s' to '%s'.", index, e.itemlink, from_name, to_name)
@@ -2189,15 +2624,18 @@
 		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")
+		local from_i, from_h, hist_i = _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)
+		local hist_u = tremove (from_h.unique, hist_i)
+		from_h.when[hist_u] = nil
+		from_h.id[hist_u] = nil
+		from_h.count[hist_u] = nil
+		g_uniques[hist_u] = nil
 		self.hist_clean = nil
 
 		self:Print("Removed history entry %d/%s from '%s'.", lootindex, e.itemlink, e.person)
@@ -2205,7 +2643,7 @@
 
 	-- 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'})
+	local deleted_cache = {}
 	function addon:history_handle_disposition (index, olddisp)
 		local e = g_loot[index]
 		-- Standard disposition has a nil entry, but that's tedious in debug
@@ -2218,15 +2656,23 @@
 		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
+			local name_i, name_h, hist_i = _history_by_loot_id (e, "mark")
+			-- remove history entry if it exists
 			if hist_i then
-				local hist_h = tremove (name_h, hist_i)
-				deleted_cache[e.unique] = hist_h
+				local c = flib.new()
+				local hist_u = tremove (name_h.unique, hist_i)
+				c.when = name_h.when[hist_u]
+				c.id = name_h.id[hist_u]
+				c.count = name_h.count[hist_u]
+				deleted_cache[hist_u] = c
+				name_h.when[hist_u] = nil
+				name_h.id[hist_u] = nil
+				name_h.count[hist_u] = nil
 				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.
+				-- So this isn't treated as an error.
 			else
 				self:Print(name_h .. "  Loot has been marked, but history will NOT be updated.", e.itemlink)
 			end
@@ -2242,32 +2688,97 @@
 			-- 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.unique and deleted_cache[e.unique] then
-				entry = deleted_cache[e.unique]
-				deleted_cache[e.unique] = nil
+			-- FIXME The deleted cache isn't nearly as useful now with the new data structures.
+			local when
+			if (not e.unique) or (#e.unique==0) then
+				when = g_today and self:format_timestamp (g_today, e) or date("%Y/%m/%d %H:%M",e.stamp)
+				e.unique = e.id .. ' ' .. when
 			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)
-			if (not e.unique) or (#e.unique==0) then
-				e.unique = entry.id .. ' ' .. entry.when
-			end
-			entry.unique = e.unique 
+			local U = e.unique
+			local c = deleted_cache[U]
+			deleted_cache[U] = nil
+			name_h.unique[#name_h.unique+1] = U
+			name_h.when[U] = c and c.when or when or date("%Y/%m/%d %H:%M",e.stamp)
+			name_h.id[U] = e.id -- c.id
+			name_h.count[U] = c and c.count or e.count
+			sort_player(name_h)
+			g_uniques[U] = { loot = index, history = name_i }
 			self.hist_clean = nil
+
+			if c then flib.del(c) end
+
 			return
 		end
 	end
+
+	-- This is not entirely "history" but not completely anything else either.
+	-- Handles the primary "Mark as <x>" action.  Arguments depend on who's
+	-- calling it:
+	--     "local", row_index, new_disposition
+	--     "remote", sender, unique_id, item_id, old_disposition, new_disposition
+	-- In the local case, must also broadcast a trigger.  In the remote case,
+	-- must figure out the corresponding loot entry (if it exists).  In both
+	-- cases, must update history appropriately.  Returns nil if anything odd
+	-- happens (not necessarily an error!); returns the affected loot index
+	-- on success.
+	function addon:loot_mark_disposition (how, ...)
+		-- This must all be filled out in all cases:
+		local e, index, olddisp, newdisp, unique, id
+		-- Only set in remote case:
+		local sender
+
+		if how == "local" then
+			index, newdisp = ...
+			index = assert(tonumber(index))
+			e = g_loot[index]
+			id = e.id
+			unique = e.unique   -- can potentially still be nil at this step
+			olddisp = e.disposition
+
+		elseif how == "remote" then
+			sender, unique, id, olddisp, newdisp = ...
+			local cache = g_uniques[unique]
+			if cache.loot then
+				index = tonumber(cache.loot)
+				e = g_loot[index]
+			end
+
+		else
+			return  -- silently ignore newer cases from newer clients
+		end
+
+		if self.debug.loot then
+			local m = ("Re-mark index %d(pre-unique %s) with id %d from '%s' to '%s'."):
+				format(index, unique, id, tostring(olddisp), tostring(newdisp))
+			self.dprint('loot', m)
+			if sender == my_name then
+				self.dprint('loot',"(Returning early from double self-mark.)")
+				return index
+			end
+		end
+
+		if not e then
+			-- say something?
+			return
+		end
+
+		e.disposition = newdisp
+		e.bcast_from = nil  -- I actually don't remember now why this gets cleared...
+		e.extratext = nil
+		self:history_handle_disposition (index, olddisp)
+		-- A unique tag has been set by this point.
+		if how == "local" then
+			unique = assert(e.unique)
+			self:vbroadcast('mark', unique, id, olddisp, newdisp)
+		end
+		return index
+	end
 end
 
 
 ------ Player communication
 do
-	local select, tconcat, strsplit = select, table.concat, strsplit
+	local select, tconcat, strsplit, unpack = select, table.concat, strsplit, unpack
 	--[[ old way:  repeated string concatenations, BAD
 		 new way:  new table on every call, BAD
 	local msg = ...
@@ -2277,10 +2788,12 @@
 	return msg
 	]]
 	local function assemble(t,...)
-		if select('#',...) > 0 then
+		local n = select('#',...)
+		if n > 0 then
 			local msg = {t,...}
 			-- tconcat requires strings, but T is known to be one already
-			for i = 2, #msg do
+			-- can't use #msg since there might be holes
+			for i = 2, n+1 do
 				msg[i] = tostring(msg[i] or "")
 			end
 			return tconcat (msg, '\a')
@@ -2331,16 +2844,31 @@
 		addon:_check_revision (revlarge)
 	end
 
+	OCR_funcs['17mark'] = function (sender, _, unique, item, old, new)
+		addon.dprint('comm', "DOTmark/17, sender", sender, "unique", unique,
+			"item", item, "from old", old, "to new", new)
+		local index = addon:loot_mark_disposition ("remote", sender, unique, item, old, new)
+		--if not addon.enabled then return end   -- hmm
+		if index and opts.chatty_on_remote_changes then
+			_notify_about_remote (sender, index, --[[from_whom=]]nil, old)
+		end
+	end
+
 	OCR_funcs['16loot'] = function (sender, _, recip, item, count, extratext)
 		addon.dprint('comm', "DOTloot/16, sender", sender, "recip", recip, "item", item, "count", count)
 		if not addon.enabled then return end
 		adduser (sender, nil, true)
+		-- Empty unique string will pass through all of the loot handling,
+		-- and then be rewritten by the history routine (into older string
+		-- of ID+date).
+		g_seeing_oldsigs = g_seeing_oldsigs or {}
+		g_seeing_oldsigs[sender] = true
 		addon:CHAT_MSG_LOOT ("broadcast", recip, --[[unique=]]"", item, count, sender, extratext)
 	end
 	OCR_funcs.loot = OCR_funcs['16loot']   -- old unversioned stuff goes to 16
 	OCR_funcs['17loot'] = function (sender, _, recip, unique, item, count, extratext)
-		addon.dprint('comm', "DOTloot, sender", sender, "recip", recip,
-			"unique", unique, "item", item, "count", count)
+		addon.dprint('comm', "DOTloot/17, sender", sender, "recip", recip,
+			"unique", unique, "item", item, "count", count, "extratext", extratext)
 		if not addon.enabled then return end
 		adduser (sender, nil, true)
 		addon:CHAT_MSG_LOOT ("broadcast", recip, unique, item, count, sender, extratext)
@@ -2354,7 +2882,7 @@
 		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,
+		addon.dprint('comm', "DOTboss/16,17, sender", sender, "reason", reason,
 			"name", bossname, "it", instancetag, "size", maxsize)
 		if not addon.enabled then return end
 		adduser (sender, nil, true)
@@ -2385,14 +2913,25 @@
 		if addon.requesting then addon:Print(sender, "declines futher broadcast requests.") end
 	end
 
-	-- Incoming message dispatcher
+	-- Incoming message disassembler and dispatcher.  The static weak table
+	-- is not my favorite approach to handling ellipses, but it lets me loop
+	-- through potential nils easily without creating a ton of garbage.
+	local OCR_data = setmetatable({}, {__mode='v'})
 	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)
+		if f then
+			--wipe(OCR_data)   costs more than its worth here
+			local n = select('#',...)
+			for i = 1, n do
+				local d = select(i,...)
+				OCR_data[i] = (d ~= "") and d or nil
+			end
+			addon.dprint('comm', ":... processing", tag, "from", sender, "with arg count", n)
+			return f(sender,tag,unpack(OCR_data,1,n))
+		end
+		addon.dprint('comm', "unknown comm message", tag, "from", sender)
 	end
-	-- Recent message cache
+	-- Recent message cache (this can be accessed via advanced options panel)
 	addon.recent_messages = create_new_cache ('comm', comm_cleanup_ttl)
 
 	function addon:OnCommReceived (prefix, msg, distribution, sender)