promotional bannermobile promotional banner

TacoTip | GearScore & iLvL

TacoTip is a character, item and guild tooltip enhancement that runs on every World of Warcraft engine family from one build — no per-client downloads, no "which version do I install" question. With many QoL updates that the prior was lacking.
Back to Files

TacoTip v0.8.3

File nameTacoTip.zip
Uploader
AcidBombAcidBomb
Uploaded
Oct 15, 2026
Downloads
5.5K
Size
1.6 MB
Flavors
Classic TBCClassicTitan Reforged ClassicRetailForever
File ID
9185315
Type
R
Release
Supported game versions
  • 12.1.5
  • 3.80.2
  • 2.5.6
  • 1.60.1
  • 1.15.9

What's new

[0.8.3] — 2026-10-14

Localization

  • FIX — the unit Level label was hardcoded English on every client. main.lua built the line as "|cFFFFFFFFLevel|r %s" while ["Level"] is translated in all 11 locale files. Every other unit-tooltip label (Target, Guild, Rank, Talents, Pet, Realm, Item Level…) already resolved through TACOTIP_LOCALE, so this was the single label bypassing the locale system — it read Level in Korean, Russian and Chinese too. Now resolves via L["Level"] with an or "Level" fallback so the line stays safe even if a future locale omits the key before the merge can backfill it. Revert-to-fail proven on all five clients; the test had to be moved out of the modern post-call block first, because that block is if (usePipeline and POSTCALLS and #POSTCALLS >= 2) and Classic never enters it — the test as first written covered only 2 of 5 clients and the mutation exposed it immediately.
  • Removed 24 unreferenced keys from all 11 locale files (263 → 239). Every one was verified dead first: absent from every L["..."] lookup in the addon, absent from TacoTip_Extras, and absent from the stale root addon copy (which carries its own self-contained Locale/ and is unaffected). They were superseded pre-OPTIONS_* option labels (Show GearScore in character frame, Unit Tooltips, Lock Position, Mover…), plus Undead, Extra, RANK_TITLE, REALM and a malformed " the Kingslayer" with a leading space. One was deliberately kept: "enUS" is not a display string at all — it is _G.TACOTIP_LOCALES["enUS"] = defaults, the registry entry that locale selection depends on, and deleting it would break every locale. Average iLvl was also removable here because TacoTip_Extras/Frames.lua:449 passes it as a literal to metricRow() rather than reading TacoTip's locale table; it becomes a proper key in Extras' own locale work.
  • load_test now asserts every L["Key"] lookup actually resolves, replacing nlocale >= 251. That magic number was only ever a proxy for "the locale table is wired": it went red when the 24 dead keys were pruned even though nothing was broken, and it would have stayed green if a referenced key were deleted as long as enough others remained. Scanning the addon sources and requiring every lookup to be non-nil in _G.TACOTIP_LOCALE tracks the real invariant and needs no updating when keys are added or removed. Two traps it has to handle, both found by making it wrong first: keys containing \n are a two-character escape in source but a real newline once loaded (24 correctly-defined keys reported as missing until the scan unescaped before comparing), and several keys are legitimately numeric — ["CHARACTER_FRAME_GS_TITLE_FONT_SIZE"] = 10, the *_XPOS / *_YPOS frame offsets — so the check must test for nil, not type(...) ~= "string". A shell-quoted grep was tried first and silently returned zero matches, which would have made the assertion a no-op that passes for the wrong reason; it reads the files in plain Lua instead. Verified: 239 lookups / 239 defined / 0 unresolved.
  • Documentation corrected: .omg/memory/localization_system.md described the fallback as a setmetatable __index, but the real implementation in _G.TacoTipApplyLocale() is a copy-merge that fills _G.TACOTIP_LOCALE in place. That distinction has two consequences now written down: main.lua's file-scope local L = _G.TACOTIP_LOCALE stays valid across a locale switch, and — because it is a copy, not a metatable — a key added to a locale file after a switch has already run will not retroactively update the merged table.

Inspect

  • FIX — the "walk out of range and it errors over and over" report (Classic Era; the same code path covers all five clients, which is why it was chased rather than patched locally). Target a player, walk out of range, and the addon kept re-asking the server for that player until you untargeted them. Four independent causes, each individually sufficient to keep the noise going:
    1. The library measured distance with an API that does not exist on any supported client. inspectDistance() read _G.UnitDistance, which appears in the generated API documentation of none of classic_era, classic_anniversary, classic_titan, forever or live (verified in the wow-ui-source checkout; the documented function is UnitDistanceSquared(unit) -> distance, checkedDistance). The helper therefore returned nil forever, which made exceedsLearnedRange() permanently false and lib.inspectRangeLimit permanently empty — the runtime range-discovery described in the entry below was unreachable code, and everything it was meant to prevent still happened, just 10 seconds later instead of immediately. UnitDistanceSquared is now read first, UnitDistance kept as a fallback, its checkedDistance flag honoured (a false there means the number is not trustworthy and is treated as absent, not as "very close"), and comparisons done on squares — the learned limit is the squared distance the client refused at, so sqrt-then-compare would round and could accept a unit the client just rejected.
    2. The rejection memory was a timer, not a state. isInspectCoolingDown() released a refused unit after INSPECT_REJECT_COOLDOWN (10s) whether or not it had come back. A target that stayed out of range therefore cost one server error every 10 seconds, forever — the "over and over" in the report. A range refusal now also sets lib.inspectOutOfRange[guid], which is not cleared by the clock: it is lifted only when the live distance signal says the unit is reachable again (measurably closer than it was at the refusal, and CheckInteractDistance agreeing). Walking back in range still inspects on the very next tick, so there is no lasting penalty for having been far away. Only a rejection whose message says range sets this — "Cannot Inspect", dead, ghosted or shielded are different problems and keep the plain 10s cooldown.
    3. Blizzard's own range gate was not being used. All five branches of Blizzard_InspectUI gate this exact call with if (CanInspect(unit, true)) then NotifyInspect(unit) end — vanilla, TBC, wrath, camelot and mainline are line-for-line identical — and the second argument is the range flag. requestInspect() now calls CanInspect(unit, true) and refuses before the request when it answers false. It is applied at the single point NotifyInspect is actually called rather than inside lib:CanInspect, because the harness pins lib:CanInspect's mainline "either signal is enough" contract on all five clients and tightening that method would break real coverage. The gate is strictly additional: a client that ignores the second argument is unaffected, because passing it can only make the decision stricter, never looser.

      ⚠️ This item is WRONG and is superseded below, in the same release. The second argument is showError, not a range flag — passing it makes the engine display a message, so this item made the very popup it was written to prevent, and CanInspect never tested distance. It is not corrected in place here so the wrong turn stays readable in the history rather than being quietly edited away. See "CanInspect's second argument is showError, not a range gate" below for the evidence, the reversal, and the three tests that had to be inverted because they asserted this item's contract.

    4. The hot paths now refuse far units before doing any work. isCloseEnoughToAsk() (~28 yards, matching the range class of interaction index 1) short-circuits the inspect queue tick (target, queue, party, up to 40 raid members) and the proximity scan, so a far-away target costs one UnitDistanceSquared call instead of the whole CanInspect chain — the tick runs twice a second, and a selected out-of-range target sits in it until it is untargeted. An unmeasurable unit returns "near", so this can only ever remove work; it can never approve a request lib:CanInspect would have refused.
  • Telemetry gained scanSkippedDistance on the pre-filter path, alongside the existing refusal / learned-range counters, so "is it still asking?" is answerable from /tacotip diag.

CanInspect's second argument is showError, not a range gate — WRONG TURN, and not yet proven to be the cause

  • The wrong turn, recorded because the code that made it is still in the tree above and in the comment that justified it. Item 3 under Inspect adds CanInspect(unit, true) to requestInspect() on the belief — stated in the old comment as settled fact — that the second argument is a range flag. It is not. Its documented meaning is "Flag — If true, the function will display an error message ('You can't inspect that unit') if you cannot inspect the specified unit. This function doesn't check whether you're close enough to the unit to inspect it; use CheckInteractDistance for that." That is two independent statements in one entry, and they invert the change: passing true makes the engine display an error, and CanInspect never tested distance in the first place. So the call site added to suppress an "out of range" popup was itself a source of one — when the gate refused, the popup had already been raised, and the code then went on to read that refusal as distance evidence, which the same documentation says it is not.
  • Where the meaning comes from, stated precisely because the first draft of this entry got it wrong. The showError meaning is documented in two external API references — warcraft.wiki.gg and the WowWiki archive — in identical wording. It is not in the local FrameXML mirror: the generated API documentation lists CanInspect with a single argument on every branch and says nothing about a second, so it cannot be cited as the source. What the mirror does supply is weaker, structural corroboration: Blizzard's own call sites use two different forms for two different purposes — Blizzard_UnitPopupShared/UnitPopupSharedButtonMixins.lua calls the one-argument form (CanInspect(contextData.unit)) to decide whether to enable a popup button, while Blizzard_InspectUI/*/Blizzard_InspectUI.lua calls the two-argument form (CanInspect(unit, true)) from InspectFrame_Show. A range flag would have one form and be passed everywhere; two forms differing exactly on the "tell the player" axis is showError. InspectFrame_Show is reached only from explicit user action (/inspect, right-click → Inspect), where a message is the intended outcome. A library asking on the player's behalf, in response to a cursor sweep, must not request one. That structure is what makes showError the better reading; it is not what establishes it, and this entry should not pretend otherwise.
  • What the old reasoning actually rested on. The comment read the generated docs' single-argument CanInspect(unit) listing as evidence, and concluded "the published Classic Era CanInspect signature takes the unit only … so the range flag above cannot be the whole gate there". That is not a deduction. The docs list one argument while Blizzard's own code passes two, so the listing is an arity gap in the FunctionParameters block, not evidence the flag is absent. A grep of the local FrameXML mirror showed Blizzard calling the two-argument form, and a call site proves a name is used — it says nothing about what an argument means. The meaning had to come from the API entry, and reading it invalidates the change. Per AGENTS.md: do not infer an API surface from a loose grep; this one was inferred from a grep and shipped.
  • What changed. requestInspect() now calls CanInspect(unit) — the unit token alone. CanInspect remains the unit-validity check it actually is (lib:CanInspect already used the one-argument form and needed no change). The distance gate on this family is CheckInteractDistance(unit, 1) — index 1 is the inspect interaction range — which is applied at the single point NotifyInspect is called and again in lib:CanInspect. The old comment block, which asserted the opposite in five places, is replaced with the reasoning above.
  • NOT CONFIRMED to be the cause of the player's popup. This removes a call that was demonstrably asking the engine to display a message; it does not establish that the popup a player reported came from this call. Attribution is still open, and the counters cannot answer it: notifies / rejected / lastError are cumulative since /reload, so a player who loads and stands away from everyone reports them all as zero — which reads identically whether no inspect was attempted or whether one was attempted and something else raised the message. Those two have opposite fixes, so a bounded capture is added to separate them (below) and the diag print is to be written once the numbers come back.
  • Three of this suite's own tests asserted the wrong contract and had to be inverted, which is the same failure one release later and one layer down:
    1. <client> range: CanInspect(unit,true) false blocks the request asserted that a showError refusal suppresses the request. It now asserts the opposite and is stronger: when the engine would refuse-and-display, the request must still go out, because the library must never ask the question that displays anything. Its mock was also corrected — it was function(unit, dist) if (dist ~= nil) then return rangeAllowed end return true end, branching on arity, which is a mock of the wrong contract rather than of the API.
    2. <client> range: back in range, the unit is inspected again moved its unit from 3yd to 2yd. Step 3 no longer blocks the request, so it now issues a real one, and a real refusal at 3yd teaches the learned ceiling 3. Asserting recovery at 3yd would have been asserting that the out-of-range memory never engages.
    3. The event-path mock CanInspect = function(u, rangeFlag) if (rangeFlag ~= nil) then return not far end return true end has a now-dead second branch. Left in place; it is harmless and keeping it avoids churn, but it is dead.
  • Every new assertion is revert-to-fail proven, on all five clients. The stub is arity-sensitive (select("#", ...) recorded per call), which no earlier CanInspect mock in this file was — they are all function() return true end, which answers identically to one argument and to two. That is precisely why the two-argument call shipped with a green suite.
    1. Re-introducing CanInspect(unit, true) → CanInspect is called with EXACTLY one argument fails on all five clients (calls=3 maxArity=2) and CanInspect's showError refusal does NOT block the request fails on all five (requests=0). The second is the corrected pre-existing test biting for the same reason, which is the point of keeping it.
    2. Disabling installNotifyCapture → a notify entry is recorded fails on all five (entries=0).
    3. Disabling installUIErrorFilter → five assertions fail on all five clients: the filter is not installed (event=nil), the return-nothing check has no filter to call (n=-1), no error entry (type=nil msg=nil), the ring buffer is not bounded by the 60 driven messages (entries=1), and the filter is not removable (removed=nil).
    • luacheck stayed clean. The new block initially introduced two warnings (clock shadowing the preceding inspect block, and a filters table mutated but never read). The local is renamed and the dead table dropped; LibForeverInspector.lua is at 0 warnings and detect_test.lua is back to its one pre-existing table.unpack warning.

Temporary attribution capture — REMOVED in the same unreleased release

⚠️ The scaffolding described in this section no longer exists in the code, and it is kept here only as the record of what was tried. The origin is not settled — see Classic Era: the out-of-range message below for exactly what the mirror does and does not show — so lib.errorLog, the NotifyInspect wrapper, the ChatFrame_AddMessageEventFilter install, the UIErrorsFrame capture frame, lib.inspectLastNotified* and the /tacotip diag attribution section were all deleted. The reason it never answered the question on its own is worth keeping: it could only observe the popup. The one hook it installed that could have suppressed anything was a hooksecurefunc, which by construction cannot change a call — which is why the operator saw "nothing changes" while the counters climbed.

  • lib.errorLog — a 50-entry ring buffer of plain tables, overwritten oldest-out-first, so it cannot grow without bound across a long session. Two entry kinds:
    • kind = "notify" — _G.NotifyInspect was actually invoked, recording the unit token, its GUID, GetTime(), the measured distance from the existing inspectDistance(), and the live answers of CheckInteractDistance(unit, 1) and CanInspect(unit) (one argument, deliberately). Captured by installing a transparent pass-through wrapper over _G.NotifyInspect at library load, so it records invocations from every caller and not only this library's own. The original is kept on lib.originalNotifyInspect.
    • kind = "error" — a UI_ERROR_MESSAGE filter fired, recording the messageType, the string resolved through GetGameMessageInfo when it exists, and GetTime().
  • The filter returns nothing. A ChatFrame_AddMessageEventFilter that returned false would suppress the message — which destroys the evidence it was installed to collect, and would look like a fix.
  • Accessors: lib:GetErrorLog() (oldest-first; returns a copy once the ring has wrapped, so a caller printing it cannot reorder the live buffer), lib:ClearErrorLog(), and lib:RemoveErrorFilter() to detach the filter in a live session.
  • /tacotip diag now prints it — a -- inspect error attribution (temporary) -- section showing the last 15 entries, one per line, with a clock delta relative to the previous entry so "did an error follow a request?" is answerable at a glance. Read-only and guarded on both CI.GetErrorLog existing and the call itself being pcall'd. A notify line prints its unit token, measured distance, interactDistance and canInspect; an error line prints its messageType and the message resolved through GetGameMessageInfo. The reading that answers the open question is a notify with distance in the 20–28yd band while interactDistance is true — that proves the NEARBY_ASK_YARDS pre-filter sits below the server's real refusal point and can be raised.
  • This scaffolding is temporary and is named as such in both the code and the diag output. It exists to attribute a popup that is not yet attributed; it is not a feature and should be removed once the origin is settled.
  • Guarded to the same standard as everything else on this path: both installs are type(...) == "function" checked and pcall'd, because neither NotifyInspect nor ChatFrame_AddMessageEventFilter is guaranteed to exist, and an unguarded raise at file scope takes the whole library down — the failure TT.LOAD_STAGE exists to name. Entry construction is inside one pcall each, so a hostile return from a distance API cannot take the inspect call down with it.

"Out of Range" is a real engine error, throttled on Retail and deliberately NOT throttled on the Classic clients

  • LE_GAME_ERR_OUT_OF_RANGE is a genuine engine error constant, delivered on UI_ERROR_MESSAGE and resolved through GetGameMessageInfo for the sound. The red-text-plus-sound popup is that path. ⚠️ The rest of this bullet was wrong; it is kept here and corrected in full under Classic Era: the out-of-range message, its mechanism, and the fix below. It read: "It is a server response: LE_GAME_ERR_* is a C-side enum an addon cannot fabricate, so the popup is proof that something sent a request the server refused — which is why the fix is about not sending the request, not about suppressing the message." A C-side enum being unfabricatable does not make it server-driven: the client's own C code raises these too. Which of the two inspect calls produces it on Classic Era is still ASSUMED, not proven — see the corrected section below — but gating only the request demonstrably changed nothing, which is what five releases of failed fixes looked like from the operator's side.

  • The per-client asymmetry that explains "it only happens on Classic Era":

    branch Blizzard_UIErrorsFrame/…/UIErrorsFrame.lua throttled?
    classic_era line 90 no — --[LE_GAME_ERR_OUT_OF_RANGE] = true,
    classic_anniversary line 90 no — same, commented
    classic_titan line 90 no — same, commented
    live (Retail) line 92 yes — live entry

    On all three Classic clients the throttle entry is commented out, so every occurrence fires a full popup with no de-duplication. On Retail it is active and repeats collapse. Same server behaviour, different noise — so "it works on Retail" is not evidence the code path differs. This is the concrete reason the report is Classic-only; it is not a different inspect rule.

  • pcall never suppressed this class of popup. UI_ERROR_MESSAGE travels into UIErrorsFrame, a different frame from the call stack, so wrapping NotifyInspect cannot stop it. Blizzard's own suppression is an event registration (Blizzard_ChatFrameBase/Shared/SlashCommands.lua unregisters and re-registers UI_ERROR_MESSAGE), not an error handler. Any earlier wording in this file that credits pcall, or a per-unit cooldown, with stopping the error is overstating it: those reduce request volume, which is a real and separate win, not popup suppression.

  • No inspect-specific LE_GAME_ERR_* exists in FrameXML — the enumerable names are all spell / resource / ability errors. Do not read that as proof none exists: only constants FrameXML references appear in a grep, and this enum is C-side. Absence in the mirror is not evidence of absence, and repeating that mistake in the other direction would be its own kind of wrong.

Classic Era: the out-of-range message — what is known, what is not, and what changed

Read this section as evidence-labelled, not as a claim that the popup's origin is proven. Four independent mirror-only audits were run against this change before it was trusted; what follows is the corrected record, including the parts the earlier draft of this section asserted without evidence.

What the mirror shows (all quotes branch-qualified; the three classic branches are byte-identical for these files).

  • The sink. Blizzard_UIErrorsFrame/Classic/UIErrorsFrame.xml:3 declares the global frame UIErrorsFrame with mixin="UIErrorsMixin", loaded on all three classic branches by Blizzard_UIErrorsFrame_Classic.toc. Classic/UIErrorsFrame.lua:140 displays a UI error with self:AddMessage(message, r, g, b, 1.0, messageType) — the id is the last argument, formally messageID (SimpleMessageFrameAPIDocumentation.lua:9-24) — and :142-146 resolves it with GetGameMessageInfo(messageType) and plays C_Sound.PlayVocalErrorSound(voiceID) or PlaySound(soundKitID). The same file's other two AddMessage calls pass only five arguments (:14 SYSMSG, :154 external/system), which is what makes the two paths distinguishable.
  • The name convention. GetGameMessageInfo returns errorName (cstring, non-nilable), soundKitID, voiceID (GameErrorDocumentation.lua:10-29), and Blizzard's own code proves the first return is a global-string name: Blizzard_Channels/VoiceUtils.lua:82-84 does local stringId = GetGameMessageInfo(gameError); … _G[stringId].
  • Blizzard's own inspect procedure, on every branch: CanInspect(unit, true) then NotifyInspect(unit), with no range check anywhere (Blizzard_InspectUI/Vanilla/…:3-10 and TBC/…:8-17 on the classic branches; Camelot/…:41-49; Mainline/…:8-17). CanInspect is documented with a single argument on all five branches; the second argument's meaning is not documented anywhere in the mirror. CheckInteractDistance is called at exactly one site per branch (UnitPopupSharedUtils.lua:194), only for buttons that declare a distance, and never with a literal index — the values in use are 0,1,2,3,4,5, where index 1 is the achievement-comparison button (Wrath/UnitPopupButtons.lua:265-266, Standard/…:5-7), not an inspect range. The inspect button inherits nil and is exempt.
  • No inspect yardage exists in the mirror at all. The only yardage constant in FrameXML is DISTANCE_THRESHOLD_SQUARED = 250*250 for compact unit frames; CheckInteractDistance is documented with names and types only. The one distance pattern Blizzard itself uses is local distance, checkedDistance = UnitDistanceSquared(unit); if checkedDistance then … end (CompactUnitFrame.lua:1069-1074) — the shape this library already had.

What the repo shows. The pre-port library this project replaced gated distance first and asked the query second (OLD/Libs/LibClassicInspector/LibClassicInspector.lua:3422-3423), and armed its suppression on the one-argument query form (:3329-3337), swallowing ERR_UNIT_NOT_FOUND / ERR_INVALID_INSPECT_TARGET / ERR_OUT_OF_RANGE and PlayVocalErrorSoundID(10) (:3339-3365). It also did not auto-inspect on PLAYER_TARGET_CHANGED or UPDATE_MOUSEOVER_UNIT; those triggers are new in the port.

What is NOT known — and this is why the fix is built the way it is.

  • No producer for an inspect refusal appears anywhere in the mirror. Nothing in any Lua on any branch says that a refused CanInspect or a refused NotifyInspect raises UI_ERROR_MESSAGE; the mirror shows the sink, not who writes to it. The earlier draft of this section asserted the query as the cause; that remains ASSUMED, not verified.
  • Whether the refusal is delivered synchronously inside the call or by event dispatch afterwards is also not in the mirror. The evidence leans to event dispatch — UIErrorsFrame consumes UI_ERROR_MESSAGE through OnEvent, and Blizzard's own suppression of these messages is event-level (Blizzard_ChatFrameBase/Shared/SlashCommands.lua:1305-1310 unregisters the event), which is only coherent if the event is the path — but it is not proven for this message.
  • The three ERR_* globals appear nowhere in the mirror, and no GlobalStrings file exists on any branch. Any design that matched on _G.ERR_* or required _G[name] == message would therefore be relying on something unverifiable. The guard does not: it matches the errorName that GetGameMessageInfo returns.

The fix, as it now stands.

  1. Order, on Classic Era, in both paths. lib:CanInspect and requestInspectImpl now read the distance signal before the query, so a unit the cheap gate rejects is never asked about. This restores the pre-port order on that family. The other four clients keep the order they shipped with.
  2. A window-based probe guard (Classic Era only). Every engine call to the inspect surface goes through one of three probes, each of which arms a short attribution window (0.5s) when it returns; an outstanding request keeps its own window. The sink — a LibDetours detour on UIErrorsFrame:AddMessage and on the sound APIs — swallows an inspect refusal that arrives either synchronously inside our call or by event dispatch inside a window, so the fix does not depend on the unanswered sync-vs-async question. It never swallows a message that lacks a messageID (a SYSMSG), never swallows while InspectFrame is shown (the player's own /inspect), and treats "this client resolved no name" as attributable because the window is only open around our own call.
  3. Classification before learning. Only ERR_OUT_OF_RANGE tightens the learned ceiling and marks the unit out of range. The other two names — and any unresolved name — get the per-unit cooldown and teach nothing; passing the unit's distance there would have set a ceiling at wherever the unit happened to stand, so a non-range refusal at 5yd would have blocked every unit at 5yd and beyond for the rest of the session.
  4. The comments that the mirror contradicts are corrected, including the two worst: "index 1 is used by NO button on any branch" (it is the achievement button's gate on Wrath and Retail) and "CheckInteractDistance(unit, 1) is what Blizzard's own unit-popup buttons and its inspect flow consult" (the popup consults it only for buttons that declare a distance; the inspect flow consults CanInspect alone). The "~28 yard" figure and the claim that NotifyInspect answers as a Lua error raised inside the call are also gone — neither is in the mirror.

Per client — one changes, and the other four change in exactly one respect.

client project constant guard installed gate order behaviour change
Classic Era WOW_PROJECT_CLASSIC (2) yes distance-first the refusal is swallowed, recorded, and (if it is a range refusal) learned from
TBC Anniversary WOW_PROJECT_BURNING_CRUSADE_CLASSIC (5) no query-first (unchanged) none beyond raise-safety
WotLK Titanforge WOW_PROJECT_WRATH_CLASSIC (11) no query-first (unchanged) none beyond raise-safety
WoW Forever WOW_PROJECT_MAINLINE (1), interface 16000–19999 no either-signal (unchanged) none beyond raise-safety
Retail WOW_PROJECT_MAINLINE (1), interface ≥ 20000 no either-signal (unchanged) none beyond raise-safety

"None beyond raise-safety" is precise, and the earlier draft's "none" was not. Every engine call on this path is now wrapped in a pcall by the probe helpers, so a call that raises reads as "no answer" instead of aborting the handler it ran inside. Same calls, same arguments, same order, same accept/reject rule on those four clients — but a raising client is handled differently, and that difference is real.

Verification. bash Tests/harness/run_all.sh — read the counts from its output. detect_test runs under lua5.1, lua5.4 and luajit. The guard's tests drive a mock client that refuses the way the report describes and, critically, emit the message after the call has returned (the async shape the mirror suggests), plus: the refusal is swallowed and recorded; a non-range refusal is swallowed but teaches no range; the ceiling still tightens for a range refusal; only the sound the client actually plays is armed, and a message carrying both ids arms only the voice; an unrelated sound and an unrelated error still play and display; the install retries when UIErrorsFrame appears late; the player's own inspect message displays while the frame is up; and a message this client cannot name is still swallowed inside the window. Six mutations, each reverted: window not armed → the async assertion fails; non-range refusals allowed to teach → the classification assertion fails; both sound latches armed → the both-ids assertion fails; install retry removed → the retry assertion fails; the InspectFrame guard removed → the player assertion fails; requiring a name → the unnamed assertion fails.

Performance / range thresholds

  • Client-family range thresholds: Classic Era uses 20 yards for out-of-range recheck and nearby-ask cutoff; larger-variant clients use 40 yards.
  • ⚠️ Those two numbers do not match the range they were introduced to match, and the mismatch was documented as if it did. The NEARBY_ASK_YARDS comment claimed it "matches the ~28 yard inspect range"; 20 ≠ 28 and 40 ≠ 28, so neither branch did. What it actually is: a conservative pre-filter, stricter than the CheckInteractDistance(unit, 1) gate it protects on Classic and looser on the others. The Classic strictness has a real cost — a player at 20–28yd is inspectable and will not be inspected, because the pre-filter refuses before lib:CanInspect is consulted. That is a missed-inspect cost, not an error cost: it cannot produce an out-of-range popup, only absent data. The value is not changed in this release; it should be set from observed client behaviour, and the client now measures itself: every refused probe tightens lib.inspectRangeLimit[family], which /tacotip diag prints as learned max range. That number — not an assumption — is what this pre-filter should be set from next: raised where the client allows more, never lowered below the learned limit.
  • INSPECT_INTERACT_DISTANCE_INDEX = 1 is an inference, not a verification, and the comment claiming otherwise has been corrected. Read off the real call sites the enum is 0 = guild/team ops, 2 = trade, 3 = duel, 4 = follow, 5 = pet-battle duel, with the base mixin and the inspect button both returning nil (which is why UnitPopupSharedUtil.IsEnabled skips the distance check for inspect entirely). Index 1 is used by no button on any branch, so "1 = inspect" is by elimination. Reasonable, externally corroborated, and still unproven — the runtime value is what CheckInteractDistance actually returns.
  • The UnitDistance fallback in inspectDistance is unreachable on all five clients. That name appears in the generated API documentation of none of classic_era, classic_anniversary, classic_titan, forever, live; only UnitDistanceSquared does. Every apparent hit for the plain form is a substring of UnitDistanceSquared or UnitDistanceCheckUpdate. Retained defensively only; do not reason about behaviour as though it can execute.
  • GearScore item-data collects: RequestLoadItemDataByID calls are now globally throttled to once per item ID per 2.0s, preventing repeated item-data collect spam during rapid tooltip refreshes.

Inspect engine — what 0.8.3 actually delivered

These are the product fixes, not test corrections. (They were misfiled under a "green for the wrong reason" heading for a while; the split is cosmetic and the content is unchanged.)

  • The request is wrapped in pcall. ⚠️ This bullet originally said "This is the fix that ends the popup". That was wrong. pcall never saw this class of popup — UI_ERROR_MESSAGE travels into UIErrorsFrame, a different frame from the call stack — and the branch it guards never ran on the client that reported the symptom, because the refusal is not a Lua error there. The wrapper is kept because a raise from NotifyInspect must never abort the inspect path, and because it is now where the request is attributed to its unit; it is not what silences anything.
  • A refused unit is remembered for 10s. One rejection is enough evidence. Previously a single mouse sweep issued one request per player per mouse move, which is exactly how the report reads: "every Time you drive with mouse over Players".
  • The refusal teaches the client its real range. The tightest distance the client has refused becomes this client's ceiling and is never exceeded again. This is deliberately runtime discovery rather than a hardcoded radius: the API docs on disk (Classic Era 1.15.9) document CanInspect as a bool that says nothing about distance, and NotifyInspect documents no error behaviour at all — so there is no documented way to ask "is this unit in inspect range". Reporting showed two users disagreeing on the trigger distance (5 metres vs 40 yards), which is consistent with a gate that does not mean what it was assumed to mean.
  • Removed the range-bypassing hysteresis added earlier in this release. It let a recently-inspected unit through a failed distance check — so a player who walked out of range was still requested, and raised the exact error it was meant to prevent. Boundary flicker now costs a skipped inspect instead of an error popup. The test that covered it asserted the wrong contract and is now inverted.
  • Nameplate fallback. The proximity scan falls back to mouseover / target / focus when no nameplate resolves to a player, so the feature still works with friendly nameplates off (the default, and the case where the nameplate pass alone does nothing). It is keyed on no plate resolved, not on nothing was enqueued — a plate that resolved but was cached or out of range means nameplates are working, and falling through on top of that would add a server request to every tick.
  • Proximity world scan: LibForeverInspector enumerates visible nameplates on a throttled ticker, distance-gates each unit via CheckInteractDistance (per-family rule), and enqueues in-range players so GearScore is collected without hovering each player. Budget + backoff guard hitching; disabled in combat. Per-plate fault isolation prevents a single bad nameplate from aborting the tick.
  • Range robustness: centralized INSPECT_INTERACT_DISTANCE_INDEX; 3-second watchdog releases stalled inspect slots so the queue cannot deadlock.
  • Cache timestamps now come from GetTime() (uptime), not time() (wall clock). time was used as a bare global and was only ever supplied by the test harness, never by production code. Uptime is monotonic, so an NTP correction or timezone change can no longer make the 300s freshness window read as expired or not-yet-valid, and it resets exactly when the in-memory cache does. The clock read is guarded, because it sits on the inspect pipeline: an unguarded raise in isCacheFresh or in cacheUnitData would abort INSPECT_READY before the callbacks fire — a tooltip that silently never populates, with nothing logged anywhere.
  • Telemetry: GetInspectStats() (notify / ready / timeout / refusals recorded / probe errors suppressed / error speech suppressed / error sound suppressed / learned max range / scan-enqueued / scan-via-fallback / scan-skipped) plus SetScanEnabled(), surfaced in /tacotip diag. The refusal counters are how a player reports whether they are still hitting this: a rising count while sweeping means the client enforces a tighter range than the gate APIs advertise. lastError is labelled last inspect-call error because it is only set when the engine call raises — it is not the refusal, and the old label read as "no refusals" while probe errors suppressed climbed beside it.

Classic Era was misclassified — and fixing that broke the event path it was protecting

  • isClassic never matched the real Classic Era client. It required clientBuildMajor == 1, but Classic Era's GetBuildInfo() puts nil in slot 4 and the build number in slot 7, so clientBuildMajor resolved to 6. Classic Era was therefore routed into the mainline "either signal is enough" CanInspect branch, which is the one policy that lets an out-of-range unit reach NotifyInspect. This is the mechanical reason the reported popup was Classic-only. Fixed by identifying Classic Era from WOW_PROJECT_ID alone — the signal that is actually distinct per Classic client (LibForeverInspector.lua:140).
  • The event paths bypassed the ask range. PLAYER_TARGET_CHANGED, UPDATE_MOUSEOVER_UNIT, UNIT_INVENTORY_CHANGED and the tooltip-show path in main.lua now all pass through isCloseEnoughToAsk, so they cannot reach NotifyInspect around the per-client ask gate.
  • inspectOutOfRange no longer lifts on "still inside 20yd". It stores the distance at refusal time and lifts only when the unit has measurably moved closer than it was at the refusal — or was unmeasurable and became measurable — while still lifting instantly when CheckInteractDistance flips false→true. The old rule lifted a refused unit sitting at 15yd immediately, so it re-asked and re-errored once per cooldown: the reported spam.
  • ⚠️ And that edit broke the event path it was protecting. isCloseEnoughToAsk was referenced from the OnEvent handler while being declared further down the file. A Lua local is only in scope from its declaration onward, so every player target and every player hover raised attempt to call global 'isCloseEnoughToAsk' (a nil value) — red text at the top of the screen, on exactly the two triggers in the report — and the handler aborted before it could inspect anything. It is now forward-declared at :3714 and assigned at :4419. This is the release's second wrong turn and it is recorded here rather than edited away, because a fix that introduces a louder bug than the one it removes is worth reading twice.
  • NotifyInspect re-checks CheckInteractDistance(unit, 1) for the Classic family at the single point it is actually called. That call, not CanInspect, is the range gate on those clients. (The earlier reasoning for also adding a second CanInspect argument was itself wrong — see the showError section above.)
  • The dead fixed-yardage recheck constant is removed. It referenced the same never-learned ceiling the UnitDistance bug had already made unreachable.
  • detect_test now fires the real PLAYER_TARGET_CHANGED / UPDATE_MOUSEOVER_UNIT handler. The suite had always claimed to drive these paths and never did — which is how a nil-global raise on every player hover shipped green.
  • /tacotip diag no longer raises a false alarm on the Classic family. The unknown-client check flagged anything that was "neither Retail nor WoW Forever", which is true by design for Classic Era, TBC Anniversary and Titanforge — so a perfectly healthy Classic Era client opened a red PROBLEMS section. It now flags only CI:IsUnknown(), a family that resolved to nothing. load_test asserts the false alarm stays gone, revert-to-fail proven.

Portrait lifecycle — the Classic destroy/hide/re-anchor

  • The portrait was hidden, re-anchored, and never restored on the Classic family. onTooltipSetUnit resolves its unit in three steps: tooltip:GetUnit(), then resolveTooltipUnit, then — only if both fail — resolveUnitTokenFromGUID(data.guid). That third step is the safety net for exactly the case where GetUnit() fails. It is structurally unreachable on Classic, because the OnTooltipSetUnit script hook is registered as function(tooltip, ...) handleTooltipSetUnit(tooltip) end and drops its varargs, so data is nil on every Classic render. Retail and WoW Forever get a real data from TooltipDataProcessor.AddTooltipPostCall and never hit the branch at all.
  • Consequences, all Classic-only: incomingGUID was always nil, so keepModel was always false; that made the unresolved branch hide the portrait and reset portraitSide / portraitAnchored (the re-anchor) every render; and the branch then returned, skipping ApplyTooltipAppearance, which holds the only portrait:Show() in the file. Nothing re-showed it. Worst case a portrait that stayed gone for the rest of that tooltip show.
  • The fix passes keepModel = not changeUnproven (main.lua:1586-1597), where changeUnproven means there is no guid, or it matches what is already rendered — i.e. nothing has been shown to have changed. On Classic that is now always true, so the hide and the anchor-epoch reset are both skipped entirely. A guid that is present and different is a real unit change and still tears down correctly. A guarded portrait:Show() restore sits behind it as a belt-and-braces for a portrait hidden by some other route.
  • Classic-specificity is proven, not asserted. Removing the fix turns the new assertion an unresolvable render does NOT leave the 3D portrait hidden red on Classic Era, TBC Anniversary and Titanforge only — Forever and Retail stay green, because they take the guid-compare keep path and never reach the defect.
  • Hide() had no counter that could see it, and the two tests named for this bug were therefore blind to it. TT.ev.portraitHide sits inside if (not retainModel), so it counts teardowns, not Hide() calls. Lifting Hide() out of its guard — exactly the shape of this bug — left both pre-existing tests green on all five clients. That gap is now closed by TT.ev.portraitModelHidden, incremented on every hide, and the reproduction is recorded in the revert-to-fail table below.
  • /tacotip diag counters were relabelled to match what they count. portrait hide counted teardowns and said otherwise; it is now portrait teardowns (hide + ClearModel), with a separate portrait Hide() calls (on clear) and portrait destroys (ClearModel). The trailing guidance — "portrait destroy should be ~1 for a character and 0 for a re-hover of the same one" — was wrong for anything but the first unit after a /reload, and now says so.
  • 13 new assertions, running on all five clients through each client's real delivery path (Classic script hook / modern post-call), covering: a same-unit re-render does not hide the portrait, a real recycle (OnTooltipCleared / OnHide) does, and the unresolved render leaves it visible. One precondition was written, found to pass for the wrong reason (non-wide tip_style rewrites lines via SetText instead of appending, so a resolved render also adds zero lines), and corrected in the test rather than the code — with the reason left in the comment.
  • ⚠️ Still outstanding, on every client: getTooltipState(tooltip).currentUnitGUID is never cleared. The guid-change teardown therefore fires on the first render of any unit after a /reload regardless of who it is, and again on every A→B→A sequence. On Retail that means re-hovering a character already seen this session still destroys and rebuilds the mesh. Diagnosable from the new counters; not fixed in this release.

Harness corrections — the range gate and CanInspect

  • A test asserted the old broken contract and had to be inverted. a refused player is asked exactly once while sweeping expected mouseover (at 40yd) to be requested once and refused. It was only ever true because the library never measured distance, so a player 40 yards away — outside inspect range — was asked anyway. With distance actually measured the correct assertion is zero requests for a unit outside inspect range, which is stronger than "one request and a refusal". The neighbouring after the cooldown, a unit beyond the learned limit is still refused was inverted the same way (mouseoverAfter == 1 → == 0), and its "both refusals happened — 40yd and 6yd" comment corrected: only one refusal can happen now, because only one unit is ever asked.
  • Three revert-to-fail probes, each of which initially passed for the wrong reason — which is the entire reason the probes exist:
    1. The CanInspect(unit, true) probe passed with the gate deleted, because the test's mock returned false for both CanInspect(unit) and CanInspect(unit, true), and lib:CanInspect's pre-existing CanInspect(unit) call was already rejecting it. The mock now returns true without the flag and only rangeAllowed with it — the real Classic-era contract — and deleting the gate now turns CanInspect(unit,true) false blocks the request red on all five clients.
    2. The pre-filter probe passed with UnitDistanceSquared unreadable, because the out-of-range scenario was simultaneously protected by the learned-range limit, so removing either defence alone changed nothing observable. The tests are now split by what can save them: the distance-measurable block exercises the pre-filter and the range flag, and a separate no-distance block (no UnitDistance and no UnitDistanceSquared at all — what every client actually shipped before this fix) exercises the state-based memory alone. Deleting the state memory in that block reproduces the reported bug exactly: 60 requests against the same out-of-range target over 600 seconds.
    3. The in-range test asserted two requests for two entry points, which failed once the request throttle worked as intended. The hover render and the proximity scan run inside the same inspect window, so the second is deliberately coalesced; the assertion now expects the single request that is the real contract.
  • New coverage, all five clients, both entry points (the tooltip hook that calls DoInspect, and the proximity scan fallback that owns target in SCAN_FALLBACK_UNITS): in-range target is inspected exactly once; out-of-range hover makes no request; out-of-range scan makes no request; a clock 600 seconds past every cooldown does not revive the request; CanInspect(unit, true) false blocks the request; back in range, the unit is inspected again. Plus the no-distance block above, and the recovery case asserted outside INSPECT_REJECT_COOLDOWN — asserting inside it would be asserting that a cooldown does not work.
  • One fault-isolation guard that is NOT revert-proven, recorded as such. A distance API raising under comparison is documented as a concern rather than a demonstrated failure: on forever and live, UnitDistanceSquared is marked SecretArguments only — its returns are plain numbers — while UnitInRange on those two branches is SecretReturns. One client build is all it takes for that to change, so both measurement helpers run their comparisons inside their pcall and report "unmeasurable" (which the pre-filter treats as near). The test pinning it passes with the fault isolation removed, because type(dist) ~= "number" short-circuits before any comparison on a hostile value — so it documents intended behaviour, it does not prove the guard is needed. Listed because a revert-proven claim it could not make is the same class of lie the rest of this file exists to stop.
  • One real defect found by the tests rather than by reading: the CanInspect(unit, true) gate originally called noteInspectRejected(unit, yards) on refusal, which tightened the learned-range ceiling to wherever the unit happened to be standing. Wrong twice over — the gate says nothing about distance, and the side effect then blocked a unit that was plainly close from ever being inspected. It now records the rejection with no distance attached, so the ceiling is only ever taught by a real refusal.
  • luacheck stayed clean. The two new blocks initially introduced 7 shadowing warnings (clock, requested, thrown, ci shadowing the surrounding inspect block) while the file was previously at 0; the new locals are renamed, and the totals before and after this change are identical at 129 warnings / 0 errors in 43 files, all pre-existing.

Tests that were green for the wrong reason — inspect pipeline

Six in total. Two from the earlier inspect work, four from the "Not in Range" work (listed above as wrong turns 1, 2 and 4).

  • The GetTime-absent check originally passed because isCacheFresh short-circuits on a nil cache entry before ever reaching the clock, and GetLastCacheTime returns the stored stamp without reading a clock at all. It now drives the real INSPECT_READY handler, which forces the read.
  • That handler was being invoked as handler("INSPECT_READY", guid), but a frame script's signature is function(self, event, ...). The event name landed in the self slot, the whole branch was skipped, and the test was green because nothing ran at all.

Tests that were green for the wrong reason — out-of-range work

Four more, from the "walk out of range" work: the three revert-to-fail probes listed above, plus a first draft of the out-of-range test that counted requests from every fallback unit rather than target. SCAN_FALLBACK_UNITS contains mouseover and focus too, each of which is legitimately asked once before its own mark is set, so the aggregate assertion was measuring something no one claimed. It passes only when the counter is narrowed to the unit actually under test.

Harness corrections — delivery, events and the scan

  • xpcall varargs shim. WoW's Lua accepts extra arguments on xpcall; stock 5.1 accepts exactly two. Blizzard's own FrameXML relies on this (xpcall(handlerInfo.handler, CallErrorHandler, self, button, cursorX, cursorY) on classic_era) and so does the bundled CallbackHandler-1.0 v7 Dispatch, which passes the event name and payload through it. Under stock lua5.1 every handler received zero arguments, so any assertion on callback arguments would have failed for a reason that does not exist in game. The harness now reproduces the client's behaviour.
  • next / xpcall were missing from the mocked environment. run() wipes the entire _G and restores a fixed list that never included them, so CallbackHandler captured local next = nil at load and every callbacks:Fire() in the suite raised inside Dispatch. No test ever fired a callback, so nothing noticed. This is the first test that does, and it asserts the real INVENTORY_READY → (guid, true, unit) contract end-to-end.
  • New coverage, all five clients: scan enqueues an in-range player; skips a fresh cache entry; respects the one-per-tick budget; survives a plate whose unit query raises (and counts it); rejects out-of-range with the correct per-family policy (Classic = CanInspect true + distance false, mainline = both false); is blocked in combat; and obeys SetScanEnabled(false). Plus the classic range gate (cold reject / recency-is-not-an-exemption / stale entry), ticker registration, watchdog release, the GetTime-absent path, and the full rejected-inspect path (escape / count / per-unit single ask / learned ceiling / cooldown expiry / fallback with no nameplates).
  • Roster auto-scan regression guard. The pre-existing party/raid inspection was already running on the queue ticker before the nameplate scan existed, and the nameplate ticker was added alongside it rather than in front of it. Nothing asserted that, which is exactly how a refactor of the pending/watchdog block could have deleted the roster arm silently. It is now asserted on all five clients with GetNamePlates forced absent, and deleting the roster arm turns it red.
  • Four wrong turns in the "Not in Range" tests, all recorded because each one hid the very bug being fixed. These are the failures that made a green suite mean nothing, and every fix below was re-verified by reverting it and watching the suite turn red:
    1. The first version of the escape test could never fail. It drove the scan with a nameplate present, and the plate walk wraps every plate body in its own pcall for fault isolation — so the refusal was swallowed by that guard and the assertion passed with the fix removed. The unprotected path is the fallback; the test now drives that, with GetNamePlates returning nothing.
    2. The first mutation was malformed. local ok, err = _G.NotifyInspect, unit assigns rather than calls, so nothing was invoked and the suite stayed green for a reason unrelated to the product.
    3. Removing the rejection guard did not bite — because it is enforced at three independent points (CanInspect, requestInspect, scanCandidate), defence in depth. It only turned red once all three were disabled, at which point the request count went from 2 to 9. The same thing happened to the fallback, which has two call sites, and to the hysteresis test, which asserted the behaviour that caused the bug.
    4. Two assertions were wrong about correct behaviour, not the reverse: the scan legitimately tries a second distinct candidate after the first is refused, and the learned ceiling legitimately tightens from 40 to 6 once a closer unit is also refused. Both were corrected to assert the real contract rather than the convenient one.
  • Every new assertion was revert-to-fail proven — twelve mutations, each turned the suite red and was then restored: scan budget, per-plate fault isolation, watchdog release, scan-ticker registration, wall-clock-vs-uptime stamps, the GetTime guard, and the roster arm; then, for the "Not in Range" work, the pcall around NotifyInspect, the per-unit rejection blacklist (all three of its call sites — see wrong turn 3), the learned-range ceiling, the nameplate fallback (both of its call sites), and finally the hysteresis removal itself, which is proven by re-introducing the bypass and watching classic range: recency does NOT exempt a unit that walked out of range turn red.

Efficiency and robustness pass — what two audits found that the suite could not

Two read-only audits were run over the whole pipeline before this release was trusted: one tracing every thread between LibForeverInspector and the rest of the repo, one on the scan pipeline itself (per-client support, redundant work, wasted calls, state growth). Both were mirror-only for Blizzard claims. What they found, and what changed:

  • The tick walked a cached raid through the entire gate chain. inspectQueueTick checked UnitExists → isCloseEnoughToAsk → lib:CanInspect for all 40 raid members before asking whether their data was already fresh — so a 40-man raid where everyone was cached cost ~40 distance probes plus ~80 classic-family probes per 0.5s tick, roughly 880 engine calls a second, to discover nothing needed doing. Freshness now comes first in all three priorities (target, queue, roster). Measured: can=0 dist=0 distSq=0 for a cached 40-man raid, against can=40 dist=40 distSq=40 when the order is reverted.
  • The tooltip was rebuilt twice for every completed inspect. The library fires INVENTORY_READY and TALENTS_READY back to back from one cacheUnitData, and main.lua handled both identically with GameTooltip:SetUnit — two full renders (gate chain, text path, and GetScore walking 18 item slots) for identical data. The second of an immediate pair is now dropped, keyed on GUID with a short window so a genuine later update still rebuilds.
  • DoInspect ran the whole gate chain on every settled render. It is the addon's hottest path — the tooltip handler calls it on every unit render — and for a unit whose data is already fresh the answer is known from memory. The freshness lookup now comes first.
  • The watchdog's documented 3s window never ran. A second, earlier release branch (now > nextInspectTime + 0.8) always fired first in real time, because the ticker runs every 0.5s — so the branch that increments timeouts was unreachable outside a loading screen, /tacotip diag under-reported stalled inspects, and a unit that never answered was re-requested at nearly twice the intended rate, forever, while it stayed targeted. One window now, named INSPECT_PENDING_TIMEOUT, and it is counted.
  • INSPECT_READY released the pending slot for any GUID. A late answer to an earlier request therefore dropped a request that was still in flight: the second unit's own answer could no longer be resolved (a queued or nameplate unit is neither target nor mouseover), so its data was discarded and requested again. The slot is now released only by its own answer — or by a unit that is gone, or a GUID-less event.
  • An uninspected player was shown a specialization anyway. GetSpecialization returned 1 for an uncached unit, which means "the first spec of its class" — so every player not yet inspected read "Arms [0/0/0]" on the classic family or "Arms" on the modern pair until INSPECT_READY re-rendered. It returns nil now, as GetLocalizedSpecName beside it already did; the callers already gate on nil.
  • The unresolved-unit portrait branch had both halves of its contract inverted. An unproven change (no GUID — every Classic render — or the same GUID) was hidden, re-anchored and re-shown: a Hide()+Show() on a 3D model frame on every render, for a portrait that had not changed. A proven different GUID — the case the comment above the call says must not leave the stale mesh up — passed keepModel = true, which means "do nothing at all", so the previous character's face stayed visible while the tooltip described someone else. Now: unproven is a complete no-op, proven-different is a full teardown, and neither re-shows.
  • Dead state removed. lib.inspectSuccessTime was written on every successful inspect and read by nothing in production (only by two test lines); its comment still described a hysteresis that had been deliberately deleted. Gone, along with the two assertions that seeded it — they passed whether or not any hysteresis existed, because the gate never read the field.
  • Diag truthfulness. The sound-kit half of the guard's work had no counter at all, so a client whose refusals are sound-only showed probe errors suppressed climbing with error speech suppressed at 0 and nothing to explain it — error sound suppressed now exists. lastError is labelled "last inspect-call error" because it is set only when the engine call raises, never by a refusal delivered as a UI error. And noteSuppressedRefusal's unused message parameter is gone.
  • The hover contract, asserted on all five clients. tooltip_test's "hovering a player NEVER auto-inspects" assertion was green for the wrong reason: the mock creates every frame shown, nothing hides InspectFrame, and lib:CanInspect refuses while the inspect frame is up — so the library issued zero requests for the whole run and the check could not tell "hover never asks" from "the library never asks at all". It would have passed with the entire request path deleted, and it only ran on the two pipeline clients. It now asserts the real contract — hovering does inspect, the request is for the hovered unit, and a burst of five renders costs at most one request — on every client, through that client's own delivery. The mock also gained a controllable clock, because with no GetTime at all the library reads 0 for every timestamp and its pacing gate can never open twice in one run.
  • The orphan-GUID portrait assertion asserted the wrong contract too. It drove a proven-different GUID through the unresolved branch and asserted the portrait stayed shown — pinning the stale-mesh bug. It now asserts both contracts where each applies: hidden for a proven change, untouched (and not even Hide()d) when nothing is proven.
  • Stale comments corrected in the library (GetModernSpecializationIcon's "the other one is a stub" — it is a full implementation; a line-number reference that had drifted), in the harness (the CanInspect(unit, TRUE) claim that contradicted the corrected block in the same file), and in README.md (the "raises from inside the call" mechanism, the deleted attribution section, the 28-yard figure, the either-signal range rule, and an INSPECT_DELAY of 0.8 that the code sets to 0.4).

Verification. bash Tests/harness/run_all.sh — 54/54. TacoTip_Extras/Tests/harness/run_all.sh — 42/42. detect_test passes under lua5.1, lua5.4 and luajit. Every new assertion was mutation-tested — the fix reverted, the assertion red, the fix restored: raid walk gates-first (can=40 dist=40 distSq=40); the early watchdog branch restored (never released, timeouts=0); the pending slot cleared unconditionally (ready 0 -> 0); the tooltip coalesce removed (SetUnit calls=2); GetSpecialization inventing spec 1 (spec=1); the old portrait shape (shown=true for a proven change, Hide() calls=1 for an unproven one); the hover path disabled (3 red on Classic, 5 on the modern pair); pacing removed (NotifyInspect calls=5 across five renders).

Deliberately not done in this release, each recorded in bench/OPEN-ITEMS.md with its evidence: memoizing GetScore per (guid, cache time); event-driven discovery (NAME_PLATE_UNIT_ADDED) instead of the 2s enumeration; bounding lib.cache; and wiring SetScanEnabled to an options toggle.

Still outstanding

Read this before trusting any "fixed" claim above.

  • The popup's origin is still NOT attributed — the earlier claim that it is was withdrawn. What is verified: the sink (UIErrorsFrame consuming UI_ERROR_MESSAGE and speaking it), Blizzard's own inspect procedure on every branch (CanInspect(unit, true) → NotifyInspect(unit), no range check), and the repo's pre-port library gating distance first and suppressing the one-argument query's refusal. What is not shown anywhere in the mirror: which call produces the refusal, and whether it is delivered synchronously or by event dispatch. The fix therefore covers both candidates rather than naming one — a short attribution window around the addon's own probes, matching the errorName GetGameMessageInfo returns. The fix itself is offline-verified only: the harness reproduces the refusal-and-display behaviour on all five clients, every new assertion was reverted to confirm it fails, and nobody has yet watched it run in a live client. That in-game pass is the one thing this section still owes.
  • NEARBY_ASK_YARDS (20 / 40) is still not set from measurement. The mirror defines no inspect yardage at all — CheckInteractDistance is documented with names and types only, and the only yardage constant anywhere in FrameXML is the compact-unit-frame 250-yard threshold — so these are deliberately conservative pre-filters, and the number to replace them with is the one the client measures itself: /tacotip diag prints learned max range, the tightest distance this client has refused at.
  • INSPECT_INTERACT_DISTANCE_INDEX = 1 is an inference inherited from the pre-port library, not a verification, and is labelled as such in the code. Index 1 is the achievement-comparison button's gate on Wrath and Retail; on Classic Era's loaded unit-popup file no button returns 1 at all; Blizzard's own inspect button declares no distance and is exempt from the gate entirely.
  • The load_test mock is still wrong — it mocks Classic as having no NineSlice, which FrameXML contradicts. The suite therefore cannot catch a border regression on Classic Era, and "54/54" does not mean the border works there.
  • currentUnitGUID is never cleared. A→B→A therefore tears the portrait down twice, which is by design (two different characters must not share a mesh) but costs a model reload each way; the first render after a /reload has no previous guid, so there is nothing to destroy there. Whether the reload is visible is unmeasured.
  • GetScore is recomputed from scratch on every render — ~20 item tables and ~40 GetItemInfo calls for data already in memory. Memoizing per (guid, cache time, stale) is the largest remaining per-render saving and was deliberately deferred out of this release because it touches the score path. Evidence and the proposed key are in bench/OPEN-ITEMS.md.
  • Discovery still enumerates nameplates every 2s instead of listening for NAME_PLATE_UNIT_ADDED, which exists on all five clients. Deferred as a behaviour change, not a defect.
  • lib.cache has no eviction. It grows with every unique GUID inspected and is only cleared by /reload. Bounded in practice by session length; recorded rather than capped.
  • SetScanEnabled has no UI. The proximity scan runs unconditionally and cannot be turned off from the options panel — there is no checkbox wired to the setter.
  • None of the portrait work has been confirmed in a live client. The code is right and the Classic-only defect is revert-to-fail proven, but nobody has watched it run.
  • The error-capture scaffolding is still in. Removed in this release, together with the hooksecurefunc capture added while diagnosing: lib.errorLog, the NotifyInspect wrapper, the ChatFrame_AddMessageEventFilter install, the UIErrorsFrame capture frame, lib.inspectLastNotified* and the /tacotip diag attribution section are all gone. What replaces them permanently is two counters — suppressed and suppressedVoice — beside the existing learnedRange, printed by /tacotip diag.

Documentation corrections

Several files in this repository asserted things about the code that were false, and false prose here has shipped wrong fixes four times in this release. The claims below were each checked against the code rather than accepted.

  • AGENTS.md carried four false portrait claims, each corrected with the line that disproves it: the modern overlay still duplicating the inline icon (it is cleared — main.lua:1906, :1992); hasDualSpec claiming dual spec on single-spec modern clients (it is a family test, false on modern by construction); portrait alpha slaved to the tooltip's (the portrait is SetAlpha(1); the only GetAlpha() left is inside a comment); and the portrait re-anchored every render (the anchor is gated and runs once per anchor epoch).
  • AGENTS.md also recommended the reverted CanInspect(unit, true) "range gate" as the fix — the very call this release backs out. Corrected, and cross-referenced to the showError section.
  • LibForeverInspector's INSPECT_INTERACT_DISTANCE_INDEX comment claimed "Verified on every supported branch." It was not. The real enum, read off Blizzard's own call sites, is 0 = guild/team ops, 2 = trade, 3 = duel, 4 = follow, 5 = pet-battle duel, with the base mixin and the inspect button both returning nil — index 1 is used by no button on any branch.
  • NEARBY_ASK_YARDS was documented as "matching the ~28 yard inspect range" while being 20 or 40. Neither branch matched. The comment now states the real relationship, the cost of the Classic strictness, and a TODO(measure) pointing at the error log.
  • The UnitDistance fallback in inspectDistance is unreachable on all five clients and is now labelled as defensive-only, so nobody reasons about behaviour as though it can execute.
  • README described hasDualSpec as a four-term predicate it no longer is, and advertised a 5-second boundary hysteresis the code explicitly refuses to have ("boundary flicker now costs a skipped inspect, not an error popup"). Both corrected.

This mod has no additional files