ONNOlympus News NetworkCurated WoW: Forever news for the Olympus army Sign in

ONN Reporter

A small addon that records where things are in WoW: Forever while you play: NPCs, quests, loot, vendors, items and flight paths. ONN Companion sends it to ONN, and every reporter's data builds the drop rates, vendor lists, quest pages and maps on the site.

Download ONN Reporter 1.2.0 (.zip, 23 KB) Get ONN Companion

Found so far

Last upload 2 h ago
1reporter
18NPCs
33quests
74items
0corpses and nodes looted
2vendors
0lines of NPC dialogue
0books and plaques

Install

Unzip it into the Interface/AddOns folder of your WoW: Forever install, so you have an ONNReporter folder there, then restart the game. Check it's on in the AddOns list on the character screen.

Keep ONN Companion running and signed in. Your uploads need the same approval as census uploads. The data goes out when WoW saves it: when you log out, type /reload or exit.

Type /onnr in game to see what it has recorded, or /onnr reset to clear it.

What it records

  • NPCs you target, mouse over or talk to: name, level, type, and where they stand (only when you're close, so the spot is accurate).
  • Loot from corpses, herbs, ore, chests and fishing: which items and how many. Each corpse counts once, so drop rates stay honest.
  • Vendors: what they sell, the price and limited stock.
  • Quests: everything in the quest windows: the description, objectives, the "do you have them yet" text, the completion text, required items and money, rewards (with amounts, spells and honor), suggested group size, who gives and ends it, and where objectives go up on the map.
  • Books, letters and plaques you read: every page.
  • Items: details and tooltips exactly as the WoW: Forever client shows them, for every item you see: loot, vendors, rewards, and any item tooltip you look at (only the item itself, never where you saw it or who had it).
  • Flight paths and map names.
  • NPC dialogue: what NPCs say in their chat windows (gossip) and the lines they say, yell or emote near you. Your character's name is swapped for $N, the way the game writes it, here and in quest and book text.

It never records other players, player chat, your gold, your bags, your character's name or anything about your account. ONN Companion keeps only those sections when it reads the file on your computer. See the privacy policy.

Source available

All of ONN Reporter's code is here, so you can see exactly what it records before you install it. It's two small files: the table of contents WoW reads, and the addon itself. It's all rights reserved: published for review. You're welcome to read it, check it and run the official download, but not to copy, modify or redistribute it. ONN Reporter is © 2026 Archway Point.

ONNReporter/ONNReporter.toc10 lines
## Interface: 16001
## Title: ONN Reporter
## Notes: Records NPCs, NPC dialogue, quest windows, books, loot, vendors and items for the Olympus News Network database. Sent by ONN Companion. Never records other players or player chat.
## Author: Archway Point
## Version: 1.2.0
## SavedVariables: ONNReporterDB
## X-Website: https://olympusnewsnetwork.com/reporter

ONNReporter.lua
ONNReporter/ONNReporter.lua568 lines
-- ONN Reporter: records game data for the Olympus News Network database.
--
-- What it records (and nothing else):
--   * NPCs you target, mouse over or talk to: name, level, type, and where they stand
--   * Loot from corpses, herbs, ore, chests and fishing (which items, how many)
--   * What vendors sell and for how much
--   * Quests: everything in the quest windows (description, objectives, the "do you have them yet"
--     and completion text, required items, rewards), who gives and ends them, and where objectives progress
--   * Books, letters and plaques you read: their pages
--   * Item details and tooltips for items you see
--   * Flight paths and map names
--   * What NPCs say: gossip windows and the lines they say, yell or emote nearby
-- It never records other players, player chat, your gold, your bags or anything about your account.
-- Your character's name is replaced with $N in any text NPCs say to you.
-- Everything is kept in ONNReporterDB (the SavedVariables file). ONN Companion reads that file
-- when WoW saves it (logout or /reload) and sends it to olympusnewsnetwork.com.

local ADDON = ...
local VERSION = "1.2.0"
local GRID = 200          -- positions are rounded to 0.5% of the map
local MAX_CELLS = 120     -- per NPC/object/quest per map, so the file stays small
local MAX_ITEMS = 40000

local db
local f = CreateFrame("Frame")

-- ---------- helpers ----------
local unpackf = table.unpack or unpack
local function pack(...) return { n = select("#", ...), ... } end
-- Call a game function without letting an error (or a missing function) break the addon.
local function safe(fn, ...)
  if type(fn) ~= "function" then return end
  local r = pack(pcall(fn, ...))
  if r[1] then return unpackf(r, 2, r.n) end
end
local function now() return time() end

-- "Creature-0-1234-0-56-12345-0000ABCDEF" -> "Creature", 12345, "0000ABCDEF"
local function parseGUID(guid)
  if type(guid) ~= "string" then return end
  local kind, _, _, _, _, id, spawn = strsplit("-", guid)
  id = tonumber(id)
  if not id then return end
  return kind, id, spawn
end

local function sourceKey(guid)
  local kind, id = parseGUID(guid)
  if kind == "Creature" or kind == "Vehicle" then return "c:" .. id, "c", id end
  if kind == "GameObject" then return "o:" .. id, "o", id end
end

local function playerPos()
  local map = safe(C_Map.GetBestMapForUnit, "player")
  if not map then return end
  local p = safe(C_Map.GetPlayerMapPosition, map, "player")
  if not p then return end
  local x, y
  if p.GetXY then x, y = p:GetXY() else x, y = p.x, p.y end
  if not x or not y or (x == 0 and y == 0) then return end
  return map, math.floor(x * GRID + 0.5), math.floor(y * GRID + 0.5)
end

local function noteMap(map)
  if not map or db.map[map] then return end
  local info = safe(C_Map.GetMapInfo, map)
  if info then db.map[map] = { n = info.name, p = info.parentMapID, t = info.mapType } end
end

local function addPos(store, id, map, x, y)
  if not map then return end
  noteMap(map)
  store[id] = store[id] or {}
  local m = store[id][map]
  if not m then m = {}; store[id][map] = m end
  local key = x .. "_" .. y
  if m[key] then m[key] = m[key] + 1; return end
  local n = 0
  for _ in pairs(m) do n = n + 1; if n >= MAX_CELLS then return end end
  m[key] = 1
end

-- Game text often includes your character's name ("Thanks, Aelric!"). Swap it for $N, the way
-- the game itself writes it, so no player names are ever kept.
local function scrub(text)
  if type(text) ~= "string" or text == "" then return text end
  local me = safe(UnitName, "player")
  if type(me) == "string" and #me > 1 then text = text:gsub(me:gsub("(%p)", "%%%1"), "$N") end
  return text:sub(1, 1500)
end

local function itemIdFromLink(link)
  if type(link) ~= "string" then return end
  return tonumber(link:match("item:(%d+)"))
end

-- ---------- items: details and tooltips, gathered in the background ----------
-- Every item you see counts: loot, vendors, quest rewards, and any item tooltip you look at
-- (bags, bank, chat links, the auction house, other players' gear when you hover it). Only the
-- item's own details are kept, never where you saw it or who had it.
local itemQueue, queued, itemCount = {}, {}, nil
local function wantItem(id)
  id = tonumber(id)
  if not id or id <= 0 or not db or queued[id] then return end
  local it = db.item[id]
  if it and it.b == db.build then return end
  queued[id] = true
  itemQueue[#itemQueue + 1] = id
end

local function tooltipLines(id)
  local data = C_TooltipInfo and safe(C_TooltipInfo.GetItemByID, id)
  if not data or not data.lines then return end
  local out = {}
  for i, line in ipairs(data.lines) do
    local l, r = line.leftText, line.rightText
    if type(l) == "string" and l:find(RETRIEVING_ITEM_INFO or "Retrieving item information", 1, true) then return false end
    if i > 1 then
      if l and l ~= "" then out[#out + 1] = (r and r ~= "") and (l .. "\t" .. r) or l end
    end
    if #out >= 30 then break end
  end
  return out
end

local function readItem(id)
  local get = (C_Item and C_Item.GetItemInfo) or GetItemInfo
  if not get then return false end
  local name, _, quality, ilvl, req, typ, sub, stack, slot, _, sell = safe(get, id)
  if not name then
    if C_Item and C_Item.RequestLoadItemDataByID then safe(C_Item.RequestLoadItemDataByID, id) end
    return false
  end
  if not itemCount then itemCount = 0; for _ in pairs(db.item) do itemCount = itemCount + 1 end end
  if itemCount >= MAX_ITEMS and not db.item[id] then return true end
  local tip = tooltipLines(id)
  if tip == false then return false end -- the tooltip isn't loaded yet: try again shortly
  if not db.item[id] then itemCount = itemCount + 1 end
  db.item[id] = { n = name, q = quality, il = ilvl, rl = req, ty = typ, st = sub, sk = stack, eq = slot, sp = sell, tip = tip, b = db.build }
  return true
end

local tries = {}
C_Timer.NewTicker(0.25, function()
  if not db or InCombatLockdown() then return end
  for _ = 1, 8 do
    local id = table.remove(itemQueue, 1)
    if not id then return end
    if readItem(id) then queued[id] = nil
    else
      tries[id] = (tries[id] or 0) + 1
      if tries[id] < 20 then itemQueue[#itemQueue + 1] = id else queued[id] = nil; tries[id] = nil end
    end
  end
end)

-- The game finished loading an item we asked about: read it right away.
local function itemLoaded(id, ok)
  id = tonumber(id)
  if id and queued[id] and ok ~= false and not InCombatLockdown() and readItem(id) then queued[id] = nil end
end

-- Any item tooltip shown anywhere.
local function hookTooltips()
  if TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall and Enum and Enum.TooltipDataType and Enum.TooltipDataType.Item then
    TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Item, function(tt, data)
      if data and data.id then wantItem(data.id) return end
      if tt and tt.GetItem then local _, link = tt:GetItem(); wantItem(itemIdFromLink(link)) end
    end)
    return
  end
  for _, tt in ipairs({ GameTooltip, ItemRefTooltip, ShoppingTooltip1, ShoppingTooltip2 }) do
    if tt and tt.HookScript then pcall(tt.HookScript, tt, "OnTooltipSetItem", function(self) local _, link = self:GetItem(); wantItem(itemIdFromLink(link)) end) end
  end
end

-- ---------- NPCs ----------
local function noteUnit(unit, near)
  if not UnitExists(unit) or UnitIsPlayer(unit) then return end
  if safe(UnitPlayerControlled, unit) then return end -- pets and minions
  local kind, id = parseGUID(UnitGUID(unit))
  if kind ~= "Creature" or not id then return end
  local e = db.npc[id] or {}
  e.n = safe(UnitName, unit) or e.n
  local lvl = safe(UnitLevel, unit)
  if type(lvl) == "number" and lvl > 0 then
    e.l = e.l and math.min(e.l, lvl) or lvl
    e.L = e.L and math.max(e.L, lvl) or lvl
  end
  e.c = safe(UnitClassification, unit) or e.c
  e.t = safe(UnitCreatureType, unit) or e.t
  local r = safe(UnitReaction, unit, "player")
  if type(r) == "number" then e.r = r end
  e.s = now()
  db.npc[id] = e
  if near then
    local map, x, y = playerPos()
    if map then addPos(db.npos, id, map, x, y) end
  end
end

-- Close enough that the player's position is the NPC's position (about 10 yards).
local function isNear(unit)
  local ok = safe(CheckInteractDistance, unit, 3)
  return ok and true or false
end

-- ---------- loot ----------
local function onLoot()
  local n = GetNumLootItems and GetNumLootItems() or 0
  if n == 0 then return end
  local fishing = IsFishingLoot and IsFishingLoot()
  local sources = {}
  for slot = 1, n do
    local link = GetLootSlotLink(slot)
    local item = itemIdFromLink(link)
    local info = { safe(GetLootSourceInfo, slot) } -- guid1, qty1, guid2, qty2, ...
    if fishing then
      local map = playerPos()
      if map then info = { "fish:" .. map, select(3, GetLootSlotInfo(slot)) or 1 } end
    end
    for i = 1, #info, 2 do
      local guid, qty = info[i], tonumber(info[i + 1]) or 1
      local key, kind, id
      if type(guid) == "string" and guid:sub(1, 5) == "fish:" then key = guid
      else key, kind, id = sourceKey(guid) end
      if key then
        local _, _, spawn = parseGUID(guid)
        sources[key] = sources[key] or { guid = guid, kind = kind, id = id, spawn = spawn, items = {} }
        if item then
          local s = sources[key].items
          s[item] = (s[item] or 0) + qty
          wantItem(item)
        end
      end
    end
  end
  local t = now()
  for key, s in pairs(sources) do
    -- Each corpse or node counts once, even if you open its loot twice.
    local seenKey = s.kind and s.guid or (key .. ":" .. t .. ":" .. math.random(1e6))
    if not db.seen[seenKey] then
      db.seen[seenKey] = t
      local L = db.loot[key] or { o = 0, i = {} }
      L.o = L.o + 1
      for item, qty in pairs(s.items) do
        local e = L.i[item] or { c = 0, q = 0 }
        e.c, e.q = e.c + 1, e.q + qty
        L.i[item] = e
      end
      db.loot[key] = L
      if s.kind == "o" then
        local map, x, y = playerPos()
        if map then addPos(db.opos, s.id, map, x, y) end
      end
    end
  end
end

-- ---------- vendors ----------
local function onMerchant()
  local kind, npc = parseGUID(UnitGUID("npc"))
  if kind ~= "Creature" then return end
  noteUnit("npc", true)
  local count = GetMerchantNumItems and GetMerchantNumItems() or 0
  local out = {}
  for i = 1, count do
    local link = GetMerchantItemLink(i)
    local id = itemIdFromLink(link)
    if id then
      local price, stack, avail, extended
      if C_MerchantFrame and C_MerchantFrame.GetItemInfo then
        local info = safe(C_MerchantFrame.GetItemInfo, i)
        if info then price, stack, avail, extended = info.price, info.stackCount, info.numAvailable, info.hasExtendedCost end
      elseif GetMerchantItemInfo then
        local _, _, p, s, a, _, _, ext = GetMerchantItemInfo(i)
        price, stack, avail, extended = p, s, a, ext
      end
      out[id] = { p = price, s = stack, a = avail, x = extended and 1 or nil }
      wantItem(id)
    end
  end
  db.vend[npc] = { t = now(), i = out }
end

-- ---------- quests ----------
local function giverKey(unit)
  local key = sourceKey(UnitGUID(unit))
  return key
end

-- Items in one part of the quest window: "choice", "reward" or "required", with how many.
local function questItems(kind, count)
  local ids, qty = {}, {}
  for i = 1, count or 0 do
    local id = itemIdFromLink(safe(GetQuestItemLink, kind, i))
    if id then
      local _, _, n = safe(GetQuestItemInfo, kind, i)
      ids[#ids + 1], qty[#qty + 1] = id, tonumber(n) or 1
      wantItem(id)
    end
  end
  return ids, qty
end

local function rewards(q)
  local r = {}
  r.c, r.cn = questItems("choice", safe(GetNumQuestChoices))
  r.g, r.gn = questItems("reward", safe(GetNumQuestRewards))
  -- Spells or abilities the quest teaches or casts on you.
  local sp = {}
  for i = 1, (safe(GetNumRewardSpells) or 0) do
    local _, name = safe(GetRewardSpell, i)
    if type(name) == "string" then sp[#sp + 1] = name end
  end
  if #sp > 0 then r.sp = sp end
  q.m = safe(GetRewardMoney) or q.m
  q.xp = safe(GetRewardXP) or q.xp
  q.hn = safe(GetRewardHonor) or q.hn
  q.r = r
end

local function questEntry(id)
  local q = db.quest[id] or {}
  db.quest[id] = q
  return q
end

local function onQuestDetail()
  local id = GetQuestID and GetQuestID()
  if not id or id == 0 then return end
  local q = questEntry(id)
  q.t = GetTitleText() or q.t
  q.d = scrub(GetQuestText()) or q.d
  q.o = scrub(GetObjectiveText()) or q.o
  local sg = safe(GetSuggestedGroupNum); if type(sg) == "number" and sg > 0 then q.sg = sg end
  -- Started from an item in your bags rather than an NPC.
  if not UnitExists("questnpc") and not UnitExists("npc") then q.si = 1 end
  local giver = giverKey("questnpc") or giverKey("npc")
  if giver then q.s = giver; if giver:sub(1, 1) == "c" then noteUnit("npc", true) end end
  rewards(q)
end

-- "Do you have the items yet?": the text, plus the items and money the quest asks for.
local function onQuestProgress()
  local id = GetQuestID and GetQuestID()
  if not id or id == 0 then return end
  local q = questEntry(id)
  q.t = GetTitleText() or q.t
  q.pt = scrub(safe(GetProgressText)) or q.pt
  q.rq, q.rqn = questItems("required", safe(GetNumQuestItems))
  local money = safe(GetQuestMoneyToGet); if type(money) == "number" and money > 0 then q.rm = money end
  local ender = giverKey("questnpc") or giverKey("npc")
  if ender then q.e = ender end
end

-- An NPC with several quests shows a greeting first.
local function onQuestGreeting()
  noteUnit("npc", true)
  local kind, id = parseGUID(UnitGUID("npc"))
  if kind ~= "Creature" or not id then return end
  local e = db.npc[id] or {}
  db.npc[id] = e
  e.gr = scrub(safe(GetGreetingText)) or e.gr
end

-- The quest log's own text, for quests picked up some other way (shared, auto-accepted...).
local function questLogText(index, q)
  if q.d and q.o then return end
  if QuestLogFrame and QuestLogFrame.IsShown and QuestLogFrame:IsShown() then return end
  local desc, obj = safe(GetQuestLogQuestText, index)
  if type(desc) == "string" and desc ~= "" then q.d = q.d or scrub(desc) end
  if type(obj) == "string" and obj ~= "" then q.o = q.o or scrub(obj) end
end

local function onQuestComplete()
  local id = GetQuestID and GetQuestID()
  if not id or id == 0 then return end
  local q = questEntry(id)
  q.t = GetTitleText() or q.t
  q.ct = GetRewardText and scrub(GetRewardText()) or q.ct
  local ender = giverKey("questnpc") or giverKey("npc")
  if ender then q.e = ender; if ender:sub(1, 1) == "c" then noteUnit("npc", true) end end
  rewards(q)
end

-- Objective progress: remember where the counter went up.
local progress = {}
local function scanQuestLog(record)
  if not C_QuestLog or not C_QuestLog.GetNumQuestLogEntries then return end
  for i = 1, C_QuestLog.GetNumQuestLogEntries() do
    local info = safe(C_QuestLog.GetInfo, i)
    if info and not info.isHeader and info.questID and info.questID > 0 then
      local id = info.questID
      local q = questEntry(id)
      q.t = info.title or q.t
      q.l = info.level or q.l
      if info.suggestedGroup and info.suggestedGroup > 0 then q.sg = info.suggestedGroup end
      questLogText(i, q)
      local objs = safe(C_QuestLog.GetQuestObjectives, id)
      if objs then
        local list, done = {}, 0
        for _, o in ipairs(objs) do
          list[#list + 1] = { o.text, o.type, o.numRequired }
          done = done + (o.numFulfilled or 0)
        end
        q.ob = list
        if record and progress[id] and done > progress[id] then
          local map, x, y = playerPos()
          if map then addPos(db.qpos, id, map, x, y) end
        end
        progress[id] = done
      end
    end
  end
end

-- ---------- flight paths ----------
local function onTaxi()
  noteUnit("npc", true)
  local map = GetTaxiMapID and GetTaxiMapID()
  if not map or not C_TaxiMap or not C_TaxiMap.GetAllTaxiNodes then return end
  noteMap(map)
  local nodes = safe(C_TaxiMap.GetAllTaxiNodes, map)
  if not nodes then return end
  db.taxi[map] = db.taxi[map] or {}
  for _, n in ipairs(nodes) do
    if n.name and n.position then
      db.taxi[map][n.nodeID or n.name] = { n = n.name, x = math.floor((n.position.x or 0) * GRID + 0.5), y = math.floor((n.position.y or 0) * GRID + 0.5) }
    end
  end
end

-- ---------- books, letters and plaques ----------
local reading = nil
local function onTextBegin()
  local title = safe(ItemTextGetItem)
  local key
  local kind, id = parseGUID(UnitGUID("npc"))
  if kind == "GameObject" and id then key = "o:" .. id end
  if not key then
    -- A book or letter in your bags: match it by name to an item we know.
    for itemId, it in pairs(db.item) do if it.n == title then key = "i:" .. itemId; break end end
  end
  if not key and type(title) == "string" then key = "n:" .. title:gsub("[^%w]", "_"):sub(1, 36) end
  if not key then reading = nil; return end
  local t = db.text[key] or { n = title, p = {} }
  t.n = title or t.n
  db.text[key] = t
  reading = t
  if kind == "GameObject" and id then local map, x, y = playerPos(); if map then addPos(db.opos, id, map, x, y) end end
end
local function onTextReady()
  if not reading then onTextBegin() end
  if not reading then return end
  local page = safe(ItemTextGetPage) or 1
  local text = scrub(safe(ItemTextGetText))
  if type(text) == "string" and text ~= "" and page >= 1 and page <= 50 then reading.p[page] = text end
end

-- ---------- what NPCs say ----------
local MAX_LINES = 40
local function addLine(id, text, kind)
  text = scrub(text)
  if not id or type(text) ~= "string" or text == "" then return end
  local e = db.npc[id] or {}
  db.npc[id] = e
  e.say = e.say or {}
  for _, l in ipairs(e.say) do
    if l[1] == text and l[2] == kind then l[3] = (l[3] or 1) + 1; return end
  end
  if #e.say < MAX_LINES then e.say[#e.say + 1] = { text, kind, 1 } end
end

-- The gossip window: the NPC's greeting and the choices it offers.
local function onGossip()
  noteUnit("npc", true)
  local kind, id = parseGUID(UnitGUID("npc"))
  if kind ~= "Creature" or not id then return end
  local e = db.npc[id] or {}
  db.npc[id] = e
  local text = C_GossipInfo and safe(C_GossipInfo.GetText) or (GetGossipText and safe(GetGossipText))
  local opts = {}
  local list = C_GossipInfo and safe(C_GossipInfo.GetOptions)
  if type(list) == "table" then
    for _, o in ipairs(list) do if o.name then opts[#opts + 1] = scrub(o.name) end; if #opts >= 15 then break end end
  end
  local qs = {}
  for _, fn in ipairs({ C_GossipInfo and C_GossipInfo.GetAvailableQuests, C_GossipInfo and C_GossipInfo.GetActiveQuests }) do
    local l = fn and safe(fn)
    if type(l) == "table" then for _, q in ipairs(l) do if q.questID then qs[#qs + 1] = q.questID end end end
  end
  e.g = { t = scrub(text), o = opts, q = qs }
end

-- NPCs talking nearby (say, yell, emote, whisper to you, boss emotes). Player chat is never read:
-- these events only carry lines spoken by NPCs.
local SPEECH = { CHAT_MSG_MONSTER_SAY = "say", CHAT_MSG_MONSTER_YELL = "yell", CHAT_MSG_MONSTER_EMOTE = "emote", CHAT_MSG_MONSTER_WHISPER = "whisper", CHAT_MSG_RAID_BOSS_EMOTE = "boss", CHAT_MSG_RAID_BOSS_WHISPER = "boss" }
local function onSpeech(ev, ...)
  local text, guid = (...), select(12, ...)
  local kind, id = parseGUID(guid)
  if kind ~= "Creature" or not id then return end
  local e = db.npc[id]
  if not e then db.npc[id] = { n = select(2, ...) }; e = db.npc[id] end
  addLine(id, text, SPEECH[ev])
end

-- ---------- setup ----------
local function prune()
  local cut = now() - 6 * 3600
  for k, t in pairs(db.seen) do if t < cut then db.seen[k] = nil end end
end

local function onEvent(ev, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12)
  if ev == "ADDON_LOADED" then
    if a1 ~= ADDON then return end
    ONNReporterDB = ONNReporterDB or {}
    db = ONNReporterDB
    for _, k in ipairs({ "npc", "npos", "loot", "seen", "vend", "quest", "qpos", "item", "opos", "taxi", "map", "text" }) do db[k] = db[k] or {} end
    local version, build = GetBuildInfo()
    db.v, db.addon, db.build, db.locale = 1, VERSION, (version or "") .. "." .. (build or ""), GetLocale()
    db.n = (db.n or 0) + 1
    prune()
    pcall(hookTooltips)
    return
  end
  if not db then return end
  if ev == "UPDATE_MOUSEOVER_UNIT" then noteUnit("mouseover", isNear("mouseover"))
  elseif ev == "PLAYER_TARGET_CHANGED" then noteUnit("target", isNear("target"))
  elseif ev == "GOSSIP_SHOW" then onGossip()
  elseif SPEECH[ev] then onSpeech(ev, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12)
  elseif ev == "TRAINER_SHOW" or ev == "BANKFRAME_OPENED" then noteUnit("npc", true)
  elseif ev == "LOOT_READY" or ev == "LOOT_OPENED" then onLoot()
  elseif ev == "MERCHANT_SHOW" then onMerchant()
  elseif ev == "TAXIMAP_OPENED" then onTaxi()
  elseif ev == "QUEST_DETAIL" then onQuestDetail()
  elseif ev == "QUEST_COMPLETE" then onQuestComplete()
  elseif ev == "QUEST_PROGRESS" then onQuestProgress()
  elseif ev == "QUEST_GREETING" then onQuestGreeting()
  elseif ev == "ITEM_TEXT_BEGIN" then onTextBegin()
  elseif ev == "ITEM_TEXT_READY" then onTextReady()
  elseif ev == "ITEM_TEXT_CLOSED" then reading = nil
  elseif ev == "QUEST_LOG_UPDATE" or ev == "QUEST_ACCEPTED" then scanQuestLog(ev == "QUEST_LOG_UPDATE")
  elseif ev == "GET_ITEM_INFO_RECEIVED" or ev == "ITEM_DATA_LOAD_RESULT" then itemLoaded(a1, a2)
  elseif ev == "PLAYER_LOGOUT" then prune()
  end
end
-- One bad event (or a value the game keeps hidden in combat) never breaks the addon.
f:SetScript("OnEvent", function(_, ...) pcall(onEvent, ...) end)
for _, ev in ipairs({ "ADDON_LOADED", "UPDATE_MOUSEOVER_UNIT", "PLAYER_TARGET_CHANGED", "GOSSIP_SHOW", "TRAINER_SHOW", "BANKFRAME_OPENED", "LOOT_READY", "LOOT_OPENED", "MERCHANT_SHOW", "TAXIMAP_OPENED", "QUEST_DETAIL", "QUEST_COMPLETE", "QUEST_LOG_UPDATE", "QUEST_ACCEPTED", "GET_ITEM_INFO_RECEIVED", "ITEM_DATA_LOAD_RESULT", "PLAYER_LOGOUT", "CHAT_MSG_MONSTER_SAY", "CHAT_MSG_MONSTER_YELL", "CHAT_MSG_MONSTER_EMOTE", "CHAT_MSG_MONSTER_WHISPER", "CHAT_MSG_RAID_BOSS_EMOTE", "CHAT_MSG_RAID_BOSS_WHISPER", "QUEST_PROGRESS", "QUEST_GREETING", "ITEM_TEXT_BEGIN", "ITEM_TEXT_READY", "ITEM_TEXT_CLOSED" }) do
  pcall(f.RegisterEvent, f, ev)
end

-- /onnr: what's been recorded so far.
SLASH_ONNREPORTER1 = "/onnr"
SlashCmdList.ONNREPORTER = function(msg)
  if not db then return end
  local function count(t) local n = 0; for _ in pairs(t) do n = n + 1 end; return n end
  if msg == "reset" then
    for _, k in ipairs({ "npc", "npos", "loot", "seen", "vend", "quest", "qpos", "item", "opos", "taxi", "map", "text" }) do db[k] = {} end
    itemCount = nil; wipe(itemQueue); wipe(queued)
    print("|cffeadfb8ONN Reporter|r: cleared. It starts recording again right away.")
    return
  end
  print(("|cffeadfb8ONN Reporter %s|r: %d NPCs, %d loot sources, %d vendors, %d quests, %d items (%d waiting to load), %d flight maps. ONN Companion sends this when you log out or /reload."):format(
    VERSION, count(db.npc), count(db.loot), count(db.vend), count(db.quest), count(db.item), #itemQueue, count(db.taxi)))
end

SHA-256 of the download (.zip): 2544117856bfdb7c03fd14e700bf9a18400e16b5cb1c20aab80be71e9c2842d0