TacoTip v0.8.3
What's new
[0.8.3] — 2026-10-14
Localization
- FIX — the unit
Levellabel was hardcoded English on every client.main.luabuilt 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 throughTACOTIP_LOCALE, so this was the single label bypassing the locale system — it readLevelin Korean, Russian and Chinese too. Now resolves viaL["Level"]with anor "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 isif (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 fromTacoTip_Extras, and absent from the stale root addon copy (which carries its own self-containedLocale/and is unaffected). They were superseded pre-OPTIONS_*option labels (Show GearScore in character frame,Unit Tooltips,Lock Position,Mover…), plusUndead,Extra,RANK_TITLE,REALMand 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 iLvlwas also removable here becauseTacoTip_Extras/Frames.lua:449passes it as a literal tometricRow()rather than reading TacoTip's locale table; it becomes a proper key in Extras' own locale work. load_testnow asserts everyL["Key"]lookup actually resolves, replacingnlocale >= 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-nilin_G.TACOTIP_LOCALEtracks 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\nare 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/*_YPOSframe offsets — so the check must test fornil, nottype(...) ~= "string". A shell-quotedgrepwas 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.mddescribed the fallback as asetmetatable__index, but the real implementation in_G.TacoTipApplyLocale()is a copy-merge that fills_G.TACOTIP_LOCALEin place. That distinction has two consequences now written down: main.lua's file-scopelocal L = _G.TACOTIP_LOCALEstays 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:
- 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 ofclassic_era,classic_anniversary,classic_titan,foreverorlive(verified in the wow-ui-source checkout; the documented function isUnitDistanceSquared(unit) -> distance, checkedDistance). The helper therefore returnednilforever, which madeexceedsLearnedRange()permanently false andlib.inspectRangeLimitpermanently 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.UnitDistanceSquaredis now read first,UnitDistancekept as a fallback, itscheckedDistanceflag honoured (afalsethere 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, sosqrt-then-compare would round and could accept a unit the client just rejected. - The rejection memory was a timer, not a state.
isInspectCoolingDown()released a refused unit afterINSPECT_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 setslib.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, andCheckInteractDistanceagreeing). 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. - Blizzard's own range gate was not being used. All five branches of
Blizzard_InspectUIgate this exact call withif (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 callsCanInspect(unit, true)and refuses before the request when it answers false. It is applied at the single pointNotifyInspectis actually called rather than insidelib:CanInspect, because the harness pinslib: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, andCanInspectnever 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 isshowError, 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. - 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 oneUnitDistanceSquaredcall instead of the wholeCanInspectchain — 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 requestlib:CanInspectwould have refused.
- The library measured distance with an API that does not exist on any supported client.
- Telemetry gained
scanSkippedDistanceon 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)torequestInspect()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; useCheckInteractDistancefor that." That is two independent statements in one entry, and they invert the change: passingtruemakes the engine display an error, andCanInspectnever 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
showErrormeaning 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 listsCanInspectwith 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.luacalls the one-argument form (CanInspect(contextData.unit)) to decide whether to enable a popup button, whileBlizzard_InspectUI/*/Blizzard_InspectUI.luacalls the two-argument form (CanInspect(unit, true)) fromInspectFrame_Show. A range flag would have one form and be passed everywhere; two forms differing exactly on the "tell the player" axis isshowError.InspectFrame_Showis 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 makesshowErrorthe 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 theFunctionParametersblock, 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. PerAGENTS.md: do not infer an API surface from a loose grep; this one was inferred from a grep and shipped. - What changed.
requestInspect()now callsCanInspect(unit)— the unit token alone.CanInspectremains the unit-validity check it actually is (lib:CanInspectalready used the one-argument form and needed no change). The distance gate on this family isCheckInteractDistance(unit, 1)— index 1 is the inspect interaction range — which is applied at the single pointNotifyInspectis called and again inlib: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/lastErrorare 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:
<client> range: CanInspect(unit,true) false blocks the requestasserted that ashowErrorrefusal 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 wasfunction(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.<client> range: back in range, the unit is inspected againmoved 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.- The event-path mock
CanInspect = function(u, rangeFlag) if (rangeFlag ~= nil) then return not far end return true endhas 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 earlierCanInspectmock in this file was — they are allfunction() 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.- Re-introducing
CanInspect(unit, true)→CanInspect is called with EXACTLY one argumentfails on all five clients (calls=3 maxArity=2) andCanInspect's showError refusal does NOT block the requestfails 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. - Disabling
installNotifyCapture→a notify entry is recordedfails on all five (entries=0). - 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).
luacheckstayed clean. The new block initially introduced two warnings (clockshadowing the preceding inspect block, and afilterstable mutated but never read). The local is renamed and the dead table dropped;LibForeverInspector.luais at 0 warnings anddetect_test.luais back to its one pre-existingtable.unpackwarning.
- Re-introducing
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, theNotifyInspectwrapper, theChatFrame_AddMessageEventFilterinstall, theUIErrorsFramecapture frame,lib.inspectLastNotified*and the/tacotip diagattribution 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 ahooksecurefunc, 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.NotifyInspectwas actually invoked, recording the unit token, its GUID,GetTime(), the measured distance from the existinginspectDistance(), and the live answers ofCheckInteractDistance(unit, 1)andCanInspect(unit)(one argument, deliberately). Captured by installing a transparent pass-through wrapper over_G.NotifyInspectat library load, so it records invocations from every caller and not only this library's own. The original is kept onlib.originalNotifyInspect.kind = "error"— aUI_ERROR_MESSAGEfilter fired, recording themessageType, the string resolved throughGetGameMessageInfowhen it exists, andGetTime().
- The filter returns nothing. A
ChatFrame_AddMessageEventFilterthat returnedfalsewould 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(), andlib:RemoveErrorFilter()to detach the filter in a live session. /tacotip diagnow 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 bothCI.GetErrorLogexisting and the call itself beingpcall'd. Anotifyline prints its unit token, measured distance,interactDistanceandcanInspect; anerrorline prints itsmessageTypeand the message resolved throughGetGameMessageInfo. The reading that answers the open question is anotifywithdistancein the 20–28yd band whileinteractDistanceistrue— that proves theNEARBY_ASK_YARDSpre-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 andpcall'd, because neitherNotifyInspectnorChatFrame_AddMessageEventFilteris guaranteed to exist, and an unguarded raise at file scope takes the whole library down — the failureTT.LOAD_STAGEexists to name. Entry construction is inside onepcalleach, 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_RANGEis a genuine engine error constant, delivered onUI_ERROR_MESSAGEand resolved throughGetGameMessageInfofor 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.luathrottled? classic_eraline 90 no — --[LE_GAME_ERR_OUT_OF_RANGE] = true,classic_anniversaryline 90 no — same, commented classic_titanline 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.
pcallnever suppressed this class of popup.UI_ERROR_MESSAGEtravels intoUIErrorsFrame, a different frame from the call stack, so wrappingNotifyInspectcannot stop it. Blizzard's own suppression is an event registration (Blizzard_ChatFrameBase/Shared/SlashCommands.luaunregisters and re-registersUI_ERROR_MESSAGE), not an error handler. Any earlier wording in this file that creditspcall, 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:3declares the global frameUIErrorsFramewithmixin="UIErrorsMixin", loaded on all three classic branches byBlizzard_UIErrorsFrame_Classic.toc.Classic/UIErrorsFrame.lua:140displays a UI error withself:AddMessage(message, r, g, b, 1.0, messageType)— the id is the last argument, formallymessageID(SimpleMessageFrameAPIDocumentation.lua:9-24) — and:142-146resolves it withGetGameMessageInfo(messageType)and playsC_Sound.PlayVocalErrorSound(voiceID)orPlaySound(soundKitID). The same file's other twoAddMessagecalls pass only five arguments (:14SYSMSG,:154external/system), which is what makes the two paths distinguishable. - The name convention.
GetGameMessageInforeturnserrorName(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-84doeslocal stringId = GetGameMessageInfo(gameError); … _G[stringId]. - Blizzard's own inspect procedure, on every branch:
CanInspect(unit, true)thenNotifyInspect(unit), with no range check anywhere (Blizzard_InspectUI/Vanilla/…:3-10andTBC/…:8-17on the classic branches;Camelot/…:41-49;Mainline/…:8-17).CanInspectis documented with a single argument on all five branches; the second argument's meaning is not documented anywhere in the mirror.CheckInteractDistanceis 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 are0,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 inheritsniland is exempt. - No inspect yardage exists in the mirror at all. The only yardage constant in FrameXML is
DISTANCE_THRESHOLD_SQUARED = 250*250for compact unit frames;CheckInteractDistanceis documented with names and types only. The one distance pattern Blizzard itself uses islocal 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
CanInspector a refusedNotifyInspectraisesUI_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 —
UIErrorsFrameconsumesUI_ERROR_MESSAGEthroughOnEvent, and Blizzard's own suppression of these messages is event-level (Blizzard_ChatFrameBase/Shared/SlashCommands.lua:1305-1310unregisters 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] == messagewould therefore be relying on something unverifiable. The guard does not: it matches theerrorNamethatGetGameMessageInforeturns.
The fix, as it now stands.
- Order, on Classic Era, in both paths.
lib:CanInspectandrequestInspectImplnow 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. - 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
LibDetoursdetour onUIErrorsFrame:AddMessageand 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 amessageID(a SYSMSG), never swallows whileInspectFrameis 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. - Classification before learning. Only
ERR_OUT_OF_RANGEtightens 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. - 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 consultsCanInspectalone). The "~28 yard" figure and the claim thatNotifyInspectanswers 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
20yards for out-of-range recheck and nearby-ask cutoff; larger-variant clients use40yards. - ⚠️ 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_YARDScomment claimed it "matches the ~28 yard inspect range";20 ≠ 28and40 ≠ 28, so neither branch did. What it actually is: a conservative pre-filter, stricter than theCheckInteractDistance(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 beforelib:CanInspectis 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 tightenslib.inspectRangeLimit[family], which/tacotip diagprints aslearned 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 = 1is 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 returningnil(which is whyUnitPopupSharedUtil.IsEnabledskips 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 whatCheckInteractDistanceactually returns.- The
UnitDistancefallback ininspectDistanceis unreachable on all five clients. That name appears in the generated API documentation of none ofclassic_era,classic_anniversary,classic_titan,forever,live; onlyUnitDistanceSquareddoes. Every apparent hit for the plain form is a substring ofUnitDistanceSquaredorUnitDistanceCheckUpdate. Retained defensively only; do not reason about behaviour as though it can execute. - GearScore item-data collects:
RequestLoadItemDataByIDcalls are now globally throttled to once per item ID per2.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.pcallnever saw this class of popup —UI_ERROR_MESSAGEtravels intoUIErrorsFrame, 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 fromNotifyInspectmust 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
CanInspectas a bool that says nothing about distance, andNotifyInspectdocuments 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/focuswhen 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:
LibForeverInspectorenumerates visible nameplates on a throttled ticker, distance-gates each unit viaCheckInteractDistance(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), nottime()(wall clock).timewas 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 inisCacheFreshor incacheUnitDatawould abortINSPECT_READYbefore 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) plusSetScanEnabled(), 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.lastErroris 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" whileprobe errors suppressedclimbed beside it.
Classic Era was misclassified — and fixing that broke the event path it was protecting
isClassicnever matched the real Classic Era client. It requiredclientBuildMajor == 1, but Classic Era'sGetBuildInfo()putsnilin slot 4 and the build number in slot 7, soclientBuildMajorresolved to6. Classic Era was therefore routed into the mainline "either signal is enough"CanInspectbranch, which is the one policy that lets an out-of-range unit reachNotifyInspect. This is the mechanical reason the reported popup was Classic-only. Fixed by identifying Classic Era fromWOW_PROJECT_IDalone — 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_CHANGEDand the tooltip-show path inmain.luanow all pass throughisCloseEnoughToAsk, so they cannot reachNotifyInspectaround the per-client ask gate. inspectOutOfRangeno 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 whenCheckInteractDistanceflips 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.
isCloseEnoughToAskwas referenced from theOnEventhandler 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 raisedattempt 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:3714and 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. NotifyInspectre-checksCheckInteractDistance(unit, 1)for the Classic family at the single point it is actually called. That call, notCanInspect, is the range gate on those clients. (The earlier reasoning for also adding a secondCanInspectargument was itself wrong — see theshowErrorsection above.)- The dead fixed-yardage recheck constant is removed. It referenced the same never-learned ceiling the
UnitDistancebug had already made unreachable. detect_testnow fires the realPLAYER_TARGET_CHANGED/UPDATE_MOUSEOVER_UNIThandler. 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 diagno 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 onlyCI:IsUnknown(), a family that resolved to nothing.load_testasserts 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.
onTooltipSetUnitresolves its unit in three steps:tooltip:GetUnit(), thenresolveTooltipUnit, then — only if both fail —resolveUnitTokenFromGUID(data.guid). That third step is the safety net for exactly the case whereGetUnit()fails. It is structurally unreachable on Classic, because theOnTooltipSetUnitscript hook is registered asfunction(tooltip, ...) handleTooltipSetUnit(tooltip) endand drops its varargs, sodataisnilon every Classic render. Retail and WoW Forever get a realdatafromTooltipDataProcessor.AddTooltipPostCalland never hit the branch at all. - Consequences, all Classic-only:
incomingGUIDwas alwaysnil, sokeepModelwas alwaysfalse; that made the unresolved branch hide the portrait and resetportraitSide/portraitAnchored(the re-anchor) every render; and the branch thenreturned, skippingApplyTooltipAppearance, which holds the onlyportrait: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), wherechangeUnprovenmeans 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 guardedportrait: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.portraitHidesits insideif (not retainModel), so it counts teardowns, notHide()calls. LiftingHide()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 byTT.ev.portraitModelHidden, incremented on every hide, and the reproduction is recorded in the revert-to-fail table below./tacotip diagcounters were relabelled to match what they count.portrait hidecounted teardowns and said otherwise; it is nowportrait teardowns (hide + ClearModel), with a separateportrait Hide() calls (on clear)andportrait 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-widetip_stylerewrites lines viaSetTextinstead 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).currentUnitGUIDis never cleared. The guid-change teardown therefore fires on the first render of any unit after a/reloadregardless 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 sweepingexpectedmouseover(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 neighbouringafter the cooldown, a unit beyond the learned limit is still refusedwas 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:
- The
CanInspect(unit, true)probe passed with the gate deleted, because the test's mock returnedfalsefor bothCanInspect(unit)andCanInspect(unit, true), andlib:CanInspect's pre-existingCanInspect(unit)call was already rejecting it. The mock now returnstruewithout the flag and onlyrangeAllowedwith it — the real Classic-era contract — and deleting the gate now turnsCanInspect(unit,true) false blocks the requestred on all five clients. - The pre-filter probe passed with
UnitDistanceSquaredunreadable, 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 (noUnitDistanceand noUnitDistanceSquaredat 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. - 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.
- The
- New coverage, all five clients, both entry points (the tooltip hook that calls
DoInspect, and the proximity scan fallback that ownstargetinSCAN_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 outsideINSPECT_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
foreverandlive,UnitDistanceSquaredis markedSecretArgumentsonly — its returns are plain numbers — whileUnitInRangeon those two branches isSecretReturns. One client build is all it takes for that to change, so both measurement helpers run their comparisons inside theirpcalland report "unmeasurable" (which the pre-filter treats as near). The test pinning it passes with the fault isolation removed, becausetype(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 callednoteInspectRejected(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. luacheckstayed clean. The two new blocks initially introduced 7 shadowing warnings (clock,requested,thrown,cishadowing 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 becauseisCacheFreshshort-circuits on a nil cache entry before ever reaching the clock, andGetLastCacheTimereturns the stored stamp without reading a clock at all. It now drives the realINSPECT_READYhandler, which forces the read. - That handler was being invoked as
handler("INSPECT_READY", guid), but a frame script's signature isfunction(self, event, ...). The event name landed in theselfslot, 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
xpcallvarargs shim. WoW's Lua accepts extra arguments onxpcall; 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 bundledCallbackHandler-1.0v7Dispatch, which passes the event name and payload through it. Under stocklua5.1every 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/xpcallwere missing from the mocked environment.run()wipes the entire_Gand restores a fixed list that never included them, soCallbackHandlercapturedlocal next = nilat load and everycallbacks:Fire()in the suite raised insideDispatch. No test ever fired a callback, so nothing noticed. This is the first test that does, and it asserts the realINVENTORY_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 =
CanInspecttrue + distance false, mainline = both false); is blocked in combat; and obeysSetScanEnabled(false). Plus the classic range gate (cold reject / recency-is-not-an-exemption / stale entry), ticker registration, watchdog release, theGetTime-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
GetNamePlatesforced 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:
- 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
pcallfor 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, withGetNamePlatesreturning nothing. - The first mutation was malformed.
local ok, err = _G.NotifyInspect, unitassigns rather than calls, so nothing was invoked and the suite stayed green for a reason unrelated to the product. - 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. - 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.
- 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
- 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
GetTimeguard, and the roster arm; then, for the "Not in Range" work, thepcallaroundNotifyInspect, 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 watchingclassic range: recency does NOT exempt a unit that walked out of rangeturn 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.
inspectQueueTickcheckedUnitExists → isCloseEnoughToAsk → lib:CanInspectfor 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=0for a cached 40-man raid, againstcan=40 dist=40 distSq=40when the order is reverted. - The tooltip was rebuilt twice for every completed inspect. The library fires
INVENTORY_READYandTALENTS_READYback to back from onecacheUnitData, andmain.luahandled both identically withGameTooltip:SetUnit— two full renders (gate chain, text path, andGetScorewalking 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. DoInspectran 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 incrementstimeoutswas unreachable outside a loading screen,/tacotip diagunder-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, namedINSPECT_PENDING_TIMEOUT, and it is counted. INSPECT_READYreleased 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.
GetSpecializationreturned1for 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 untilINSPECT_READYre-rendered. It returns nil now, asGetLocalizedSpecNamebeside 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 — passedkeepModel = 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.inspectSuccessTimewas 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 suppressedclimbing witherror speech suppressedat 0 and nothing to explain it —error sound suppressednow exists.lastErroris labelled "last inspect-call error" because it is set only when the engine call raises, never by a refusal delivered as a UI error. AndnoteSuppressedRefusal's unusedmessageparameter 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 hidesInspectFrame, andlib:CanInspectrefuses 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 noGetTimeat 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 (theCanInspect(unit, TRUE)claim that contradicted the corrected block in the same file), and inREADME.md(the "raises from inside the call" mechanism, the deleted attribution section, the 28-yard figure, the either-signal range rule, and anINSPECT_DELAYof 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 (
UIErrorsFrameconsumingUI_ERROR_MESSAGEand 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 theerrorNameGetGameMessageInforeturns. 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 —CheckInteractDistanceis 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 diagprintslearned max range, the tightest distance this client has refused at.INSPECT_INTERACT_DISTANCE_INDEX = 1is 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_testmock is still wrong — it mocks Classic as having noNineSlice, which FrameXML contradicts. The suite therefore cannot catch a border regression on Classic Era, and "54/54" does not mean the border works there. currentUnitGUIDis 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/reloadhas no previous guid, so there is nothing to destroy there. Whether the reload is visible is unmeasured.GetScoreis recomputed from scratch on every render — ~20 item tables and ~40GetItemInfocalls 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 inbench/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.cachehas 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.SetScanEnabledhas 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 thehooksecurefunccapture added while diagnosing:lib.errorLog, theNotifyInspectwrapper, theChatFrame_AddMessageEventFilterinstall, theUIErrorsFramecapture frame,lib.inspectLastNotified*and the/tacotip diagattribution section are all gone. What replaces them permanently is two counters —suppressedandsuppressedVoice— beside the existinglearnedRange, 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.mdcarried 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);hasDualSpecclaiming 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 isSetAlpha(1); the onlyGetAlpha()left is inside a comment); and the portrait re-anchored every render (the anchor is gated and runs once per anchor epoch).AGENTS.mdalso recommended the revertedCanInspect(unit, true)"range gate" as the fix — the very call this release backs out. Corrected, and cross-referenced to theshowErrorsection.LibForeverInspector'sINSPECT_INTERACT_DISTANCE_INDEXcomment 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 returningnil— index 1 is used by no button on any branch.NEARBY_ASK_YARDSwas 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 aTODO(measure)pointing at the error log.- The
UnitDistancefallback ininspectDistanceis unreachable on all five clients and is now labelled as defensive-only, so nobody reasons about behaviour as though it can execute. - README described
hasDualSpecas 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

