changeset 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 bb19899c65a7
children 5edaac60449b
files LibFarmbuyer.lua core.lua gui.lua verbage.lua
diffstat 4 files changed, 1053 insertions(+), 302 deletions(-) [+]
line wrap: on
line diff
--- a/LibFarmbuyer.lua	Sat May 12 11:08:23 2012 +0000
+++ b/LibFarmbuyer.lua	Tue May 29 22:50:09 2012 +0000
@@ -9,9 +9,10 @@
 
 - tableprint(t[,f])
   A single print() call to the contents of T, including nils; strings are
-  cleaned up with respect to embedded '|'/control chars.  If a function F is
-  passed, calls that instead of print().  Returns the accumulated string and
-  either T or the returned values of F, depending on which was used.
+  cleaned up with respect to embedded '|'/control chars.  A single space
+  is used during concatenation of T.  If a function F is passed, calls that
+  instead of print().  Returns the accumulated string and either T or the
+  returned values of F, depending on which was used.
 
 - safeprint(...)
   Same as tableprint() on the argument list.  Returns the results of tableprint.
@@ -54,7 +55,7 @@
   Ditto for table recycling.
 ]]
 
-local MAJOR, MINOR = "LibFarmbuyer", 17
+local MAJOR, MINOR = "LibFarmbuyer", 18
 assert(LibStub,MAJOR.." requires LibStub")
 local lib = LibStub:NewLibrary(MAJOR, MINOR)
 if not lib then return end
@@ -245,7 +246,8 @@
 	_G.safeprint = lib.safeprint
 	_G.safeiprint = lib.safeiprint
 	function lib.tabledump(t)
-		_G.UIParentLoadAddOn("Blizzard_DebugTools")
+		-- Should instead load this and then call the subcommands directly.
+		--_G.UIParentLoadAddOn("Blizzard_DebugTools")
 		_G.LibF_DEBUG = t
 		_G.SlashCmdList.DUMP("LibF_DEBUG")
 	end
--- 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)
--- a/gui.lua	Sat May 12 11:08:23 2012 +0000
+++ b/gui.lua	Tue May 29 22:50:09 2012 +0000
@@ -23,14 +23,24 @@
 --eoi_st_otherrow_bgcolortable["realm"] = eoi_st_otherrow_bgcolortable["time"]
 local eoi_st_otherrow_bgcolortable_default
 local eoi_st_lootrow_col3_colortable = {
-	[""]	= { text = "", r = 1.0, g = 1.0, b = 1.0, a = 1.0 },
-	shard	= { text = "shard", r = 0xa3/255, g = 0x35/255, b = 0xee/255, a = 1.0 },
-	offspec	= { text = "offspec", r = 0.78, g = 0.61, b = 0.43, a = 1.0 },
-	gvault	= { text = "guild vault", r = 0x33/255, g = 1.0, b = 0x99/255, a = 1.0 },
+	normal	= { text = "",            r = "ff", g = "ff", b = "ff" },
+	shard	= { text = "shard",       r = "a3", g = "35", b = "ee" },
+	offspec	= { text = "offspec",     r = "c6", g = "9b", b = "6d" },
+	gvault	= { text = "guild vault", r = "33", g = "ff", b = "99" },
 }
-local function eoi_st_lootrow_col3_colortable_func (data, cols, realrow, column, table)
+for k,v in pairs(eoi_st_lootrow_col3_colortable) do
+	-- for chat output by core code
+	v.hex = "|cff" .. v.r .. v.g .. v.b
+	-- for lib-st
+	v.r = tonumber(v.r,16)/255
+	v.g = tonumber(v.g,16)/255
+	v.b = tonumber(v.b,16)/255
+	v.a = 1
+end
+addon.disposition_colors = eoi_st_lootrow_col3_colortable
+local function eoi_st_lootrow_col3_colortable_func (data, _, realrow)
 	local disp = data[realrow].disposition
-	return eoi_st_lootrow_col3_colortable[disp or ""]
+	return eoi_st_lootrow_col3_colortable[disp or 'normal']
 end
 addon.time_column1_used_mt = { __index = {
 	[2] = {value=""},
@@ -69,6 +79,27 @@
 local mkbutton
 local tabs_OnGroupSelected_func, tabs_generated_text_OGS
 
+-- Class color support
+local class_colors-- = {}
+do
+	local function fill_out_class_colors()
+		class_colors = CUSTOM_CLASS_COLORS or RAID_CLASS_COLORS
+		-- If we were dependant on lib-st calling this function (via a
+		-- 'color' field in eoi_st_cols[2]), then this would have to be deep
+		-- copied and an "a=1" field added to each.  But as we have to use
+		-- this ourselves via DoCellUpdate, we can just share tables and
+		-- pass an alpha value manually during cell update.
+		--for class,color in pairs(CUSTOM_CLASS_COLORS or RAID_CLASS_COLORS) do
+		--	class_colors[class] = { r = color.r, g = color.g, b = color.b, a = 1 }
+		--end
+	end
+	fill_out_class_colors()
+	if CUSTOM_CLASS_COLORS and CUSTOM_CLASS_COLORS.RegisterCallback then
+		CUSTOM_CLASS_COLORS:RegisterCallback(fill_out_class_colors)
+	end
+	addon.class_colors = class_colors
+end
+
 -- Working around this bug:
 -- http://forums.wowace.com/showpost.php?p=295202&postcount=31
 do
@@ -265,22 +296,22 @@
 				e.cols = {
 					{value = textured},
 					{value = e.person},
-					{ color = eoi_st_lootrow_col3_colortable_func }
+					{}
 				}
 				-- This is horrible. Must do better.
 				if e.extratext then for k,v in pairs(eoi_st_lootrow_col3_colortable) do
 					if v.text == e.extratext then
-						e.disposition = k
+						e.disposition = k ~= 'normal' and k or nil
 						--e.extratext = nil, not feasible
 						break
 					end
 				end end
-				local ex = e.disposition or ""
-				ex = eoi_st_lootrow_col3_colortable[ex].text
+				local ex = eoi_st_lootrow_col3_colortable[e.disposition or 'normal'].text
 				if e.bcast_from and display_bcast_from and e.extratext then
 					ex = e.extratext .. " (from " .. e.bcast_from .. ")"
 				elseif e.bcast_from and display_bcast_from then
-					ex = ex .. " (from " .. e.bcast_from .. ")"
+					ex = ex .. (e.disposition and " " or "")
+					     .. "(from " .. e.bcast_from .. ")"
 				elseif e.extratext then
 					ex = e.extratext
 				end
@@ -366,18 +397,19 @@
 			col1.OLn   = #player
 			col1.value = player.name   -- may spiffy this up in future
 
-			for li,loot in ipairs(player) do
+			for li,unique in ipairs(player.unique) do
 				local col2 = new()
 				col2.OLi   = li
 				local col3 = new()
-				col3.value = loot.when
+				col3.value = player.when[unique]
 
-				local itexture = GetItemIcon(loot.id)
-				local iname, ilink, iquality = GetItemInfo(loot.id)
+				local id = player.id[unique]
+				local itexture = GetItemIcon(id)
+				local iname, ilink, iquality = GetItemInfo(id)
 				local textured
 				if itexture and iname then
 					textured = eoi_st_textured_item_format:format (itexture,
-						ITEM_QUALITY_COLORS[iquality].hex, iname, loot.count or "")
+						ITEM_QUALITY_COLORS[iquality].hex, iname, player.count[unique] or "")
 				else
 					textured = eoi_st_textured_item_format:format ([[ICONS\INV_Misc_QuestionMark]],
 						ITEM_QUALITY_COLORS[ITEM_QUALITY_COMMON].hex, 'UNKNOWN - REDISPLAY LATER', "")
@@ -401,7 +433,78 @@
 end
 
 -- Debugging tooltip
-do
+if true then
+	local tt
+	local function _create_tooltip()
+		tt = CreateFrame("GameTooltip")
+		UIParentLoadAddOn("Blizzard_DebugTools")
+
+		tt:SetBackdrop{
+			bgFile = [[Interface\Tooltips\UI-Tooltip-Background]],
+			edgeFile = [[Interface\Tooltips\UI-Tooltip-Border]],
+			tile = true,
+			tileSize = 8,
+			edgeSize = 12,
+			insets = { left = 2, right = 2, top = 2, bottom = 2 }
+		}
+		tt:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r,
+			TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
+		tt:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g,
+			TOOLTIP_DEFAULT_COLOR.b)
+		tt:SetMovable(false)
+		tt:EnableMouse(false)
+		tt:SetFrameStrata("TOOLTIP")
+		tt:SetToplevel(true)
+		tt:SetClampedToScreen(true)
+
+		local font = CreateFont("OuroLootDebugFont")
+		font:CopyFontObject(GameTooltipTextSmall)
+		if IsAddOnLoaded"tekticles" then    -- maybe check for one of the sharedmedia things?
+			font:SetFont([[Interface\AddOns\tekticles\Calibri.ttf]], 9)
+		else
+			font:SetFont([[Fonts\FRIZQT__.TTF]], 9)
+		end
+
+		local left, right, prevleft
+		-- Only create as many lines as we might need (the auto growth
+		-- by Add*Line does odd things sometimes).
+		for i = 1, math.max(DEVTOOLS_MAX_ENTRY_CUTOFF,15)+5 do
+			prevleft = left
+			left = tt:CreateFontString(nil,"ARTWORK")
+			right = tt:CreateFontString(nil,"ARTWORK")
+			left:SetFontObject(font)
+			right:SetFontObject(font)
+			tt:AddFontStrings(left,right)
+			if prevleft then
+				left:SetPoint("TOPLEFT",prevleft,"BOTTOMLEFT",0,-2)
+			else
+				left:SetPoint("TOPLEFT",10,-10)  -- top line
+			end
+			right:SetPoint("RIGHT",left,"LEFT")
+		end
+		tt.AddMessage = tt.AddLine
+
+		_create_tooltip = nil
+	end
+
+	function _build_debugging_tooltip (parent, index)
+		local e = g_loot[index]; assert(type(e)=='table')
+		if not tt then _create_tooltip() end
+		tt:SetOwner (parent, "ANCHOR_LEFT", -15, -5)
+		tt:ClearLines()
+
+		local real = DEFAULT_CHAT_FRAME
+		DEFAULT_CHAT_FRAME = tt
+		DevTools_Dump{ [index] = e }
+		DEFAULT_CHAT_FRAME = real
+
+		tt:Show()
+	end
+
+	function _hide_debugging_tooltip()
+		if tt then tt:Hide() end
+	end
+else
 	-- Fields to put in the tooltip (maybe move these into the options window
 	-- if I spend too much time fiddling).
 	local loot = {'person', 'id', 'unique', 'disposition', 'count', 'variant'}
@@ -677,12 +780,8 @@
 		until rowi >= fencepost
 	end,
 
-	["Mark as normal"] = function(rowi,disp) -- broadcast the change?  ugh
-		local olddisp = g_loot[rowi].disposition
-		g_loot[rowi].disposition = disp
-		g_loot[rowi].bcast_from = nil
-		g_loot[rowi].extratext = nil
-		addon:history_handle_disposition (rowi, olddisp)
+	["Mark as normal"] = function(rowi,disp)
+		addon:loot_mark_disposition ("local", rowi, disp)
 	end,
 
 	["Show only this player"] = function(rowi)
@@ -1002,7 +1101,6 @@
 	local cell = e.cols[column]
 
 	cellFrame.text:SetText(cell.value)
-	cellFrame.text:SetTextColor(1,1,1,1)
 
 	if e.person_class then
 		local icon
@@ -1019,11 +1117,14 @@
 		icon:SetTexCoord(unpack(CLASS_ICON_TCOORDS[e.person_class]))
 		icon:Show()
 		cellFrame.text:SetPoint("LEFT", icon, "RIGHT", 1, 0)
+		local color = class_colors[e.person_class]
+		cellFrame.text:SetTextColor(color.r,color.g,color.b,1)
 	else
 		if cellFrame.icontexture then
 			cellFrame.icontexture:Hide()
 			cellFrame.text:SetPoint("LEFT", cellFrame, "LEFT")
 		end
+		cellFrame.text:SetTextColor(1,1,1,1)
 	end
 
 	--if e.kind ~= 'loot' then
@@ -1046,6 +1147,7 @@
 	{  -- col 3
 		name	= "Notes",
 		width	= 250,
+		color	= eoi_st_lootrow_col3_colortable_func,
 	},
 }
 
@@ -1624,7 +1726,7 @@
 		grp:SetTitle("Debugging/Testing Options      [not saved across sessions]")
 
 		w = mkbutton("EditBox", 'comm_ident', addon.ident,
-			[[Disable the addon, change this field (click Okay or press Enter), then re-enable the addon.]])
+			[[Set tracking to 'Disabled' in the top-right dropdown, then change this field (click Okay or press Enter).]])
 		w:SetRelativeWidth(0.2)
 		w:SetLabel("Addon channel ID")
 		w:SetCallback("OnTextChanged", adv_careful_OnTextChanged)
@@ -1662,25 +1764,23 @@
 			if mod then
 				mod:EnableMod()
 				addon:Print("Now tracking ID",mod.creatureId)
-			else addon:Print("Can do nothing; DBM testing mod wasn't loaded.") end
+			else
+				addon:Print("Can do nothing; DBM testing mod wasn't loaded.")
+			end
 		end)
 		w:SetDisabled(addon.bossmod_registered ~= 'DBM')
 		grp:AddChild(w)
 
 		w = mkbutton("GC", [[full GC cycle]])
 		w:SetRelativeWidth(0.1)
-		w:SetCallback("OnClick", function() collectgarbage() end)
+		w:SetCallback("OnClick", function()
+			local before = collectgarbage('count')
+			collectgarbage('collect')
+			local after = collectgarbage('count')
+			addon:Print("Collected %d KB, %d KB still in use by Lua universe.", before-after, after)
+		end)
 		grp:AddChild(w)
 
-		--[==[ this has been well and truly debugged by now
-		w = mkbutton("EditBox", nil, addon.loot_pattern:sub(17), [[]])
-		w:SetRelativeWidth(0.35)
-		w:SetLabel("CML pattern suffix")
-		w:SetCallback("OnEnterPressed", function(_w,event,value)
-			addon.loot_pattern = addon.loot_pattern:sub(1,16) .. value
-		end)
-		grp:AddChild(w) ]==]
-
 		w = GUI:Create("Spacer") w:SetFullWidth(true) w:SetHeight(1) grp:AddChild(w)
 
 		local simple = GUI:Create("SimpleGroup")
@@ -1709,7 +1809,7 @@
 		w:SetFullWidth(true)
 		w:SetType("checkbox")
 		w:SetLabel("GOP history mode")
-		w:SetValue(false)
+		w:SetValue(addon.history_suppress)
 		w:SetCallback("OnValueChanged", function(_w,event,value) addon.history_suppress = value end)
 		simple:AddChild(w)
 		w = mkbutton("Clear All & Reload",
@@ -1763,10 +1863,15 @@
 		grp:ResumeLayout()
 		container:AddChild(grp)
 		GUI:ClearFocus()
+		container:SetScroll(1000)  -- scrollframe's max value
 	end
 
 	-- Initial lower panel function
 	local function adv_lower (container, specials)
+		local spacer = GUI:Create("Spacer")
+		spacer:SetFullWidth(true)
+		spacer:SetHeight(5)
+		container:AddChild(spacer)
 		local speedbump = GUI:Create("InteractiveLabel")
 		speedbump:SetFullWidth(true)
 		speedbump:SetFontObject(GameFontHighlightLarge)
@@ -1781,6 +1886,10 @@
 			--return tabs_OnGroupSelected_func(container.parent,"OnGroupSelected","opt")
 		end)
 		container:AddChild(speedbump)
+		spacer = GUI:Create("Spacer")
+		spacer:SetFullWidth(true)
+		spacer:SetHeight(5)
+		container:AddChild(spacer)
 	end
 
 	tabs_OnGroupSelected["opt"] = function(container,specials)
@@ -1816,7 +1925,7 @@
 			[[Register "/loot" as a slash command in addition to the normal "/ouroloot".  Relog to take effect.]])
 		grp:AddChild(w)
 
-		-- chatty mode
+		-- chatty boss mode
 		w = mkoption('chatty_on_kill', "Be chatty on boss kill", 0.49,
 			[[Print something to chat output when DBM tells Ouro Loot about a successful boss kill.]])
 		grp:AddChild(w)
@@ -1838,42 +1947,103 @@
 
 		-- showing the "(from Rebroadcasterdude)" in the notes column
 		w = mkoption('display_bcast_from', "Show rebroadcasting player", 0.49,
-			[[Include "(from Player_Name)" in the Notes column for loot that was broadcast to you.]],
+			[[Include "from PlayerName" in the Notes column for loot that was broadcast to you.  (Not included in forum output).]],
 			function(_w,_e,value)
 				opts.display_bcast_from = value
 				addon.loot_clean = nil
 			end)
 		grp:AddChild(w)
 
+		-- prefilling g_uniques with history
+		w = mkoption('precache_history_uniques', "Prescan for faster handling", 0.49,
+			[[See description under +Help -- Handy Tips -- Prescanning> for instructions.]])
+		grp:AddChild(w)
+
+		w = GUI:Create("Spacer") w:SetFullWidth(true) w:SetHeight(1) grp:AddChild(w)
+
 		-- possible keybindings
 		do
-			local pair = GUI:Create("SimpleGroup")
-			pair:SetLayout("Flow")
-			pair:SetRelativeWidth(0.95)
+			local pair = GUI:Create("InlineGroup")
+			pair:SetLayout("List")
+			pair:SetRelativeWidth(0.49)
 			local editbox, checkbox
 			editbox = mkbutton("EditBox", nil, opts.keybinding_text,
 				[[Keybinding text format is fragile!  Relog to take effect.]])
-			editbox:SetRelativeWidth(0.5)
+			editbox:SetFullWidth(true)
 			editbox:SetLabel("Keybinding text")
 			editbox:SetCallback("OnEnterPressed", function(_w,event,value)
 				opts.keybinding_text = value
 			end)
 			editbox:SetDisabled(not opts.keybinding)
-			checkbox = mkoption('keybinding', "Register keybinding", 0.5,
+			checkbox = mkoption('keybinding', "Register keybinding", 1,
 				[[Register a keybinding to toggle the loot display.  Relog to take effect.]],
 				function (_w,_,value)
 					opts.keybinding = value
 					editbox:SetDisabled(not opts.keybinding)
 				end)
+			checkbox:SetFullWidth(true)
 			pair:AddChild(checkbox)
 			pair:AddChild(editbox)
 			grp:AddChild(pair)
 		end
 
+		-- chatty disposition/assignment changes
+		do
+			local chatgroup = GUI:Create("InlineGroup")
+			chatgroup:SetLayout("List")
+			chatgroup:SetRelativeWidth(0.49)
+			chatgroup:SetTitle("Remote Changes Chat")
+			local toggle, editbox
+			toggle = mkoption('chatty_on_remote_changes', "Be chatty on remote changes", 1,
+				[[Print something to chat when other users change recorded loot.]],
+				function (_w,_,value)
+					opts.chatty_on_remote_changes = value
+					editbox:SetDisabled(not opts.chatty_on_remote_changes)
+				end)
+			toggle:SetFullWidth(true)
+			chatgroup:AddChild(toggle)
+			w = GUI:Create("Label")
+			w:SetFullWidth(true)
+			w:SetText("This controls the output of the |cff00ffff'Be chatty on remote changes'|r option.  If this field is a number, it designates which chat frame to use.  Otherwise it is the Lua variable name of a frame with AddMessage capability.")
+			chatgroup:AddChild(w)
+			editbox = mkbutton("EditBox", nil, opts.chatty_on_remote_changes_frame,
+				[[1 = default chat frame, 2 = combat log, etc]])
+			editbox:SetFullWidth(true)
+			editbox:SetLabel("Output Chatframe")
+			editbox:SetCallback("OnTextChanged", adv_careful_OnTextChanged)
+			editbox:SetCallback("OnEnterPressed", function(_w,event,value)
+				local prev = opts.chatty_on_remote_changes_frame
+				value = value:trim()
+				value = tonumber(value) or value
+				if addon:_set_remote_change_chatframe (value) then
+					opts.chatty_on_remote_changes_frame = value
+					_w:SetText(tostring(value))
+					_w.editbox:ClearFocus()
+				else
+					_w:SetText(tostring(prev))
+				end
+			end)
+			editbox:SetDisabled(not opts.chatty_on_remote_changes)
+			chatgroup:AddChild(editbox)
+			w = mkbutton("Chat Frame Numbers",
+				[[Print each chat window number in its own frame, for easy reference in the editing field.]])
+			w:SetFullWidth(true)
+			--w:SetDisabled(not opts.chatty_on_remote_changes)
+			w:SetCallback("OnClick", function()
+				for i = 1, NUM_CHAT_WINDOWS do
+					local cf = _G['ChatFrame'..i]
+					if not cf then break end
+					addon:CFPrint (cf, "This is frame number |cffff0000%d|r.", i)
+				end
+			end)
+			chatgroup:AddChild(w)
+			grp:AddChild(chatgroup)
+		end
+
 		-- boss mod selection
 		w = GUI:Create("Spacer")
 		w:SetFullWidth(true)
-		w:SetHeight(20)
+		w:SetHeight(2)
 		grp:AddChild(w)
 		do
 			local list = {}
@@ -1898,7 +2068,7 @@
 		-- item filters
 		w = GUI:Create("Spacer")
 		w:SetFullWidth(true)
-		w:SetHeight(20)
+		w:SetHeight(2)
 		grp:AddChild(w)
 		do
 			local warntext = "At least one of the items in the filter list was not in your game client's cache.  This is okay.  Just wait a few seconds, display some other Ouro Loot tab, and then display Options again."
@@ -1934,7 +2104,7 @@
 			w:SetEditBoxTooltip("Link items which should no longer be tracked.")
 			w:SetList(filterlist)
 			w:SetCallback("OnTextEnterPressed", function(_w, _, text)
-				local iname, ilink, iquality = GetItemInfo(strtrim(text))
+				local iname, ilink, iquality = GetItemInfo(text:trim())
 				if not iname then
 					return addon:Print("Error:  %s is not a valid item name/link!", text)
 				end
@@ -1963,7 +2133,7 @@
 			w:SetEditBoxTooltip("Link items which should be automatically marked as guild vault.")
 			w:SetList(vaultlist)
 			w:SetCallback("OnTextEnterPressed", function(_w, _, text)
-				local iname, ilink, iquality = GetItemInfo(strtrim(text))
+				local iname, ilink, iquality = GetItemInfo(text:trim())
 				if not iname then
 					return addon:Print("Error:  %s is not a valid item name/link!", text)
 				end
@@ -2425,7 +2595,7 @@
 local function eoi_st_insert_OnAccept_boss (dialog, data, data2)
 	if data.all_done then
 		-- It'll probably be the final entry in the table, but there might have
-		-- been real loot happening at the same time.
+		-- been real loot happening while the user was clicking and typing.
 		local boss_index = addon._addBossEntry{
 			kind		= 'boss',
 			bossname	= (OuroLootSV_opts.snarky_boss and addon.boss_abbrev[data.name] or data.name) or data.name,
@@ -2487,7 +2657,7 @@
 		--local real_rebroadcast, real_enabled = addon.rebroadcast, addon.enabled
 		--g_rebroadcast, g_enabled = false, true
 		data.display:Hide()
-		local loot_index = addon:CHAT_MSG_LOOT ("manual", data.recipient, data.name, data.notes)
+		local loot_index = assert(addon:CHAT_MSG_LOOT ("manual", data.recipient, data.name, data.notes))
 		--g_rebroadcast, g_enabled = real_g_rebroadcast, real_g_enabled
 		local entry = tremove(g_loot,loot_index)
 		tinsert(g_loot,data.rowindex,entry)
--- a/verbage.lua	Sat May 12 11:08:23 2012 +0000
+++ b/verbage.lua	Tue May 29 22:50:09 2012 +0000
@@ -4,7 +4,7 @@
 
 - implement ack, then fallback to recording if not ack'd
 
-- special treatment for recipes / BoE items?  default to guild vault?
+- special treatment for recipes / BoE items?  default to guild vault?  (DONE for user-configurable list of items defaulting to crafting drops)
 
 - [DONE,TEST,comm] rebroadcasting entire boss sections, entire days.  (TODO: maybe only whisper
 to specific people rather than broadcast.)
@@ -81,6 +81,10 @@
 				value = "slashies",
 				text = "Slash Commands",
 			},
+			{
+				value = "prescan",
+				text = "Prescanning",
+			},
 		},
 	},
 	{
@@ -103,7 +107,10 @@
 -- paragraphs.  This file needs to be edited with a text editor that doesn't
 -- do anything stupid by placing extra spaces at the end of lines.
 do
-local replacement_colors = { ["+"]="|cff30adff", ["<"]="|cff00ff00", [">"]="|r" }
+local replacement_colors = {
+	["+"]="|cff30adff", -- blue: right-click options, control panel names
+	["<"]="|cff00ff00", -- light green, tab titles and generic highlighting
+	[">"]="|r" }
 local T={}
 T.about = [[
 Ouro Loot is the fault of Farmbuyer of Ouroboros on US-Kilrogg.  Bug reports,
@@ -470,10 +477,42 @@
 "gray"/"grey" are all the same, "4", "epic", "purple" are the same, and so on.
 ]]
 
+T.tips_prescan = [[
+When loot is manipulated, the history of previous loot entries must be scanned
+to determine whether you already have any information for that loot.  In this
+case, "loot manipulation" includes things like receiving loot broadcasts from
+other players, marking older loot as being disenchanted, and so on.
+
+You can speed up these actions by turning on the +Prescan for faster handling>
+toggle on the <Options> tab.  This is a tradeoff:  loot manipulation will
+go faster, but data from the prescan will use up more memory.  (If you end up
+doing things to every single piece of loot, you would use up all that memory
+eventually.  But if you don't, then that memory is essentially wasted.)
+
+This prescanning is only done for the specific realm on which you're playing.
+Much depends on how far back you preserve history.  The more history you keep
+for a given realm, then...
+
+[option on] ...the more memory this option uses...
+
+[option off] ...the longer loot work takes...
+
+...while manipulating loot for that realm.  See the tradeoff?
+
+The prescan is only done once, during login.  It will print out how much
+additional memory is used, so you can better decide whether this option is
+worth turning on.  This also means that you need to relog or /reload to have
+the option take effect once you enable it.
+]]
+
 T.todo = [[
 If you have ideas or complaints or bug reports, first check the Bugs subcategories
 to see if they're already being worked on.  Bug reports are especially helpful
-if you can include a screenshot (in whatever image format you find convenient).
+if you can include a screenshot (in whatever image format you find convenient),
+and a copy of your SavedVariables file.  This is found in your World of Warcraft
+installation folder, named "WTF/Account/you/SavedVariables/Ouro_Loot.lua"
+(where "you" is your account name).  You may need to compress this file
+before the ticket system will accept it.
 
 Click the "About" line on the left for contact information.
 ]]
@@ -482,6 +521,10 @@
 <Things Which Might Surprise You> (and things I'm not sure I like in the
 current design):
 
+Manipulating existing information while logged into a realm other than the
+realm represented by that same information can yield strange results, including
+outright breakage.
+
 If you relog (or get disconnected) while in a raid group, behavior when you log
 back in can be surprising.  If you have already recorded loot (and therefore
 the loot list is restored), then OL assumes it's from the current raid and should
@@ -494,12 +537,6 @@
 the full loot list.  Restoring will get you a blank first tab and whatever you
 previously had in the various generated text tabs.
 
-Using the right-click menu to change an item's treatment (shard, offspec, etc)
-does not broadcast that change to anyone else who is also tracking.  Changing
-the item and then selecting "rebroadcast this item" <does> include that extra
-info.  Automatically doing that on the initial "mark as xxx" action would
-be... tricky.
-
 The generated forum text tries to only list the name of the instance if it has
 not already been listed, or if it is different than the instance of the previous
 boss.  If you relog, the "last printed instance name" will be forgotten, and