the audio crackle is gone, and a trainer answers a click as a trainer

- the audio crackle was our own stall sampler: `/usr/bin/sample` suspends every thread of the task for each of its snapshots, CoreAudio's realtime IO thread included, and coreaudiod times the client out — every HAL overload of the session sat inside a sample of our own process (three for three by timestamp), and the watchdog fires on exactly the stalls the crackle lands on (a desktop switch's `nextDrawable` block, a world entry's compile burst); in-frame sampling now waits for a run nobody can hear; and the output stream is ours — kira's renderer runs 256-frame chunks on a realtime render thread (time-constraint policy, joined to the device's IO workgroup) into a lock-free ring, the IOProc copies one device buffer and touches nothing else (≤ 0.01 ms), the device buffer goes 2048 → 512, a lost or changed default device rebuilds the stream (cpal's no-op error callback on macOS had paused it forever), a debug allocator aborts on any allocation inside either realtime path, and per-cycle meters name a skipped cycle's layer; `SoundBufferSize` is registered at the reference's own FMOD dial, and `audiowatch.py` puts the OS side — thread bands, coreaudiod's overload verdicts — on the same timeline
- right-clicking a trainer, questgiver, innkeeper, spirit healer, petitioner, tabard designer or battlemaster sent `CMSG_GOSSIP_HELLO` — eight of the reference's fourteen service arms collapsing into one packet, because the dispatch keyed on the CURSOR kind and Speak covers eight bits; the reference runs two structurally identical ladders over `UNIT_NPC_FLAGS` (one for the cursor at `0x482336`, one for the send at `0x5f0289`), so the ladder is transcribed, first match wins low to high: questgiver → `CMSG_QUESTGIVER_HELLO`, trainer → `CMSG_TRAINER_LIST`, petitioner → `CMSG_PETITION_SHOWLIST`, and the spirit healer and innkeeper send NOTHING on the click — they cache the guid and raise the confirm, whose Lua accept sends with a zero guid — both ghost-gated at entry, so a living player clicking a menuless spirit healer now gets exactly what the reference gives: nothing; the stable-master conjunct and the banker/auctioneer fork fall out of the bit order for free
- the -1 blood id is unfilled, not none, and the tests now name the creatures: -1 splits families that share a directory (QuillBoar beside QuillBoarWarrior, GnollMelee beside GnollCaster) and splits single creatures shipped under two rows (a Baby Murloc is both), so a "bloodless" reading would put non-bleeding quilboar beside bleeding quilboar warriors in one Razorfen room; `violenceLevel` 2 IS the reference's default — formatted out of a per-region cap table at `0x86c3f8`, and the one capped locale is koKR, whose green-blood row the table predicts from bytes alone; a spurt whose victim authors no attachment point is dropped, not relocated, and no creature-keyed suppression exists anywhere on the spurt path (ten stop conditions, none keyed on the creature)
- twenty dead copies of ours (`PanelTemplates_*`, `TabButtonTemplate`, `FauxScrollFrame_*`, `ScrollFrame_OnLoad`, `ScrollingEdit_*`) that a later chain entry had been shadowing are deleted under a gate that fails any such shadow, and deleting them turned 141 tests red on four bugs live since the kit swap: lists populated before showing (the reference gates every scroll-bar write on `IsVisible()`, so both arrows were dead on every faux list), every faux window wired to a `frame.updateFunc` the reference does not have (the bar moved, the list did not follow), a trainer pitch declared `local` where an `<OnVerticalScroll>` chunk cannot see it, and the armed-wheel guard bypassed by the template's own `<OnMouseWheel>`; nine faux windows (Skill, Craft, TradeSkill, Auction, QuestLog, Friends, ScriptLog) are still on `updateFunc` and their lists do not scroll until each is rewired

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:09:38 +07:00
co-authored by Claude Fable 5.1
parent dd7de2aa41
commit a8c9bc37a2
72 changed files with 2943 additions and 1092 deletions
Generated
+14 -72
View File
@@ -121,18 +121,6 @@ dependencies = [
"libc",
]
[[package]]
name = "alsa"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
@@ -308,6 +296,12 @@ dependencies = [
"libloading",
]
[[package]]
name = "assert_no_alloc"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ca83137a482d61d916ceb1eba52a684f98004f18e0cafea230fe5579c178a3"
[[package]]
name = "assert_type_match"
version = "0.1.1"
@@ -501,6 +495,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"arboard",
"assert_no_alloc",
"avian3d",
"benilla-assets",
"benilla-formats",
@@ -511,8 +506,8 @@ dependencies = [
"bevy",
"bevy_egui",
"bytemuck",
"coreaudio-rs 0.13.0",
"cosmic-text",
"cpal 0.17.1",
"crossbeam-channel",
"fontdb",
"half",
@@ -523,6 +518,8 @@ dependencies = [
"mint",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-foundation 0.2.2",
"rand 0.8.6",
"raw-window-handle",
@@ -865,7 +862,7 @@ dependencies = [
"bevy_reflect",
"bevy_transform",
"coreaudio-sys",
"cpal 0.15.3",
"cpal",
"rodio",
"tracing",
]
@@ -2644,14 +2641,14 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa 0.9.1",
"alsa",
"core-foundation-sys",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2 0.4.3",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
@@ -2661,36 +2658,6 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "cpal"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa 0.10.0",
"coreaudio-rs 0.13.0",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2 0.5.0",
"ndk 0.9.0",
"ndk-context",
"num-derive",
"num-traits",
"objc2 0.6.4",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.62.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -4111,7 +4078,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "065632c16b39f4c00bc5514de0698474d1b6c36120743a154c78ca27ffc1ffe1"
dependencies = [
"atomic-arena",
"cpal 0.17.1",
"glam 0.33.2",
"mint",
"pastey",
@@ -4281,15 +4247,6 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -4715,16 +4672,6 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@@ -4759,7 +4706,6 @@ dependencies = [
"objc2 0.6.4",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4791,9 +4737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.4",
]
@@ -4860,8 +4804,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
@@ -5697,7 +5639,7 @@ version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
dependencies = [
"cpal 0.15.3",
"cpal",
"lewton",
]
+22 -9
View File
@@ -22,7 +22,7 @@ default = ["dev"]
# re-exported here (1175). Their `dev` is default-on too, so without the round trip a
# `--no-default-features` build gets the feature back through unification — and the binary
# carries the build machine's source-tree path after all. Same trap as the shim's, one level down.
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui", "dep:libc"]
dev = ["benilla-assets/dev", "benilla-formats/dev", "benilla-world/dev", "dep:bevy_egui"]
# Opt-in Tracy profiling: `cargo run --features tracy` streams per-system `info_span!` markers (on the
# hot systems) + render-pass CPU timings to a Tracy capture (decision 0026 — the dev/player seam;
# a `docs/howto/running.md` this once cited never landed).
@@ -61,9 +61,10 @@ include_dir = "0.7"
# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric
# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine
# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report
# is about ("250 % CPU at 59 fps"). Already in the tree transitively; declared so the call site is
# an honest dependency.
libc = { version = "0.2", optional = true }
# is about ("250 % CPU at 59 fps"). Was dev-only; since 1857 the output backend needs it in every
# build too (`mach_absolute_time`, the host clock its meters read; `thread_policy_set`, the render
# thread's realtime policy).
libc = "0.2"
# Resample varying-size layer BLPs to a uniform size for the terrain texture array; `png` so we can
# encode the cursor image into data an NSImage can load (macOS hardware cursor).
image = { version = "0.25", default-features = false, features = ["png"] }
@@ -97,11 +98,16 @@ cosmic-text = "0.16"
# public-in-crate types). Pinned to the exact version `cosmic-text` 0.16 depends on so there's one
# `fontdb` in the graph.
fontdb = "0.23"
# kira's own backend dependency, named directly so the mixer can hand cpal an explicit device buffer
# size instead of accepting `BufferSize::Default` (decision 1026). `CpalBackendSettings`' fields ARE
# cpal types, so this must stay the exact version kira resolves — one cpal in the graph.
cpal = "0.17"
kira = { version = "0.12", default-features = false, features = ["cpal", "mp3", "wav", "pcm", "adpcm"] }
# kira renders the mix; the device it plays through is OURS (`sound::output`, decision 1857) —
# kira's own cpal backend is not enabled, and cpal is out of the graph on our path with it: on
# macOS it could neither report a lost default device nor rebuild on one, and it discarded the
# IO-cycle timestamp the deadline meters need.
kira = { version = "0.12", default-features = false, features = ["mp3", "wav", "pcm", "adpcm"] }
# The realtime-audio allocation tripwire (decision 1857): `assert_no_alloc(|| …)` around the output
# IO callback and the render pass aborts a DEBUG build on any allocation inside them, so a
# realtime-safety regression fails a test or a smoke instead of reaching an ear. Release builds
# compile it out (`disable_release`, the crate's default) and carry no allocator wrapper.
assert_no_alloc = "1.1"
# kira's own decoder, named directly for one trait: the mixer feeds streams through its own
# `MediaSource` so kira's per-stream decode thread runs OUR code and can promote its QoS (decision
# 1109 — a bare-spawned decode thread starves under the world-entry burst and zero-fills the mix).
@@ -141,6 +147,13 @@ smithay-clipboard = "0.7"
# hide revert to the arrow); we bypass it with NSCursor + NSWindow::disableCursorRects. See
# docs/knowledge/camera-control.md.
[target.'cfg(target_os = "macos")'.dependencies]
# The audio device layer (decision 1857, `sound::output::coreaudio`): the HAL output unit through
# coreaudio-rs's `AudioUnit`, and the AudioObject property/listener API through the objc2 CoreAudio
# bindings — the two crates cpal itself stood on, now used directly. The versions are the ones the
# graph already carries; the feature sets are the ones cpal proved to compile against.
coreaudio-rs = { version = "0.13", default-features = false, features = ["audio_unit", "core_audio"] }
objc2-core-audio = { version = "0.3", default-features = false, features = ["std", "AudioHardware"] }
objc2-core-audio-types = { version = "0.3", default-features = false, features = ["std", "CoreAudioBaseTypes"] }
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = [
"NSApplication",
@@ -320,6 +320,10 @@
end
FauxScrollFrame_Update(scroll, table.getn(page.items),
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
-- The trough is OURS — the reference has no trough at all, so nothing in its kit shows
-- one. The owner pairs it with the bar the reference's `_Update` just decided about
-- (1860); `BenillaScrollBar_SetShown` used to do it and is off this path now.
BenillaScrollTrough_Follow(scroll:GetName())
KeyBindings_UpdateUnbindKey()
end
+28 -4
View File
@@ -3471,10 +3471,34 @@
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-32" y="28"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>
ScrollFrame_OnLoad()
this.updateFunc = KeyBindingsPage_Update
</OnLoad>
<OnLoad>ScrollFrame_OnLoad()</OnLoad>
<!-- The reference's kit writes `frame.offset` ONLY
from here (its `updateFunc` field does not
exist); without this the bar moves and the list
does not follow. `KEYBINDINGS_ROW_HEIGHT` is a
file-scope global in KeyBindingsPage.xml, so it
is visible to this handler's own chunk — a
`local` would not be (1860). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(KEYBINDINGS_ROW_HEIGHT, KeyBindingsPage_Update)</OnVerticalScroll>
<!-- **The armed guard has to be restated HERE.**
While a capsule is armed the wheel is a BIND,
never a scroll (1.12's law; the body below
guards its own arm the same way). Our retired
`FauxScrollFrameTemplate` was a plain `<Frame>`
with no wheel handler, so the body's guard was
the only path. The reference's template
inherits `UIPanelScrollFrameTemplate`, which
carries `<OnMouseWheel>` of its own — and that
one fires on this frame, ahead of the body,
bypassing the guard entirely. An instance's
handler REPLACES the inherited one, so this is
the reference's verb with our law back in front
of it (1860). -->
<OnMouseWheel>
if not KeyBindingsPage.selected then
ScrollFrameTemplate_OnMouseWheel(arg1)
end
</OnMouseWheel>
</Scripts>
</ScrollFrame>
</Frames>
@@ -329,7 +329,11 @@
-- `offset * valueStep`, so this SetValue writes the value the Slider already holds and
-- its change-gate (decision 0250) drops it before it could re-enter this function.
-- Kept because it is the reference's line, not because it does anything.
if not FauxScrollFrame_Update(scrollFrame, numFactions, NUM_FACTIONS_DISPLAYED, REPUTATIONFRAME_FACTIONHEIGHT) then
local showBar = FauxScrollFrame_Update(scrollFrame, numFactions, NUM_FACTIONS_DISPLAYED, REPUTATIONFRAME_FACTIONHEIGHT)
-- Our trough is not the reference's business: it follows the bar the ref just decided
-- about (1860).
BenillaScrollTrough_Follow("ReputationListScrollFrame")
if not showBar then
getglobal("ReputationListScrollFrameScrollBar"):SetValue(0)
end
local factionOffset = FauxScrollFrame_GetOffset(scrollFrame)
@@ -435,9 +439,8 @@
function ReputationFrame_OnLoad()
local frame = this
frame:RegisterEvent("UPDATE_FACTION")
-- The faux kit's one wiring requirement: the owner names its own repaint so a drag of
-- the scroll bar re-binds the rows (ScrollTemplates.xml's `frame.updateFunc` contract).
getglobal("ReputationListScrollFrame").updateFunc = ReputationFrame_Update
-- The repaint is wired in the scroll frame's own `<OnVerticalScroll>` below, which is
-- where the reference's kit puts it (1860).
end
function ReputationFrame_OnShow()
@@ -744,11 +747,19 @@
<!-- The faux scroll frame (ref l.556-602): 296x354 at the page's TOPRIGHT(-66,-76),
which puts its left edge on the header column and its right edge 66 in from the
page. updateFunc is set in ReputationFrame_OnLoad. -->
page. Its `<OnVerticalScroll>` is what re-binds the rows (1860). -->
<ScrollFrame name="ReputationListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="296" y="354"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-66" y="-76"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here: the bar's
OnValueChanged calls `SetVerticalScroll`, which lands in this handler.
Our transcription used a `frame.updateFunc` field instead, which the
reference does not have — without this the bar moves and the list does
not follow (1860). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(REPUTATIONFRAME_FACTIONHEIGHT, ReputationFrame_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The two newbie tips over the column headings (ref l.603-630): invisible frames
exactly covering their FontString. `enableMouse` added, same engine rule as the bar
+25 -169
View File
@@ -96,79 +96,6 @@
if frame.SetVerticalScroll then frame:SetVerticalScroll(value) end
end
-- OnLoad: a fresh scroll frame is at the top with an empty range and both arrows greyed
-- (ref UIPanelTemplates.lua l.244-252, verbatim but for the one added line). The ref's own
-- name, because this is the ref's own function: `FauxScrollFrameTemplate` inherits
-- `UIPanelScrollFrameTemplate`, whose `<OnLoad>` is exactly this.
--
-- The added line is `SetValueStep(1)`: our bar's step is also its ARROW/WHEEL step
-- (BenillaScrollBar_Step), so a bar that has not been through an _Update yet would step by
-- nothing. The ref has no such coupling — its arrows move by half the bar's height.
-- The ScrollingEdit trio (ref UIPanelTemplates.lua l.307-317) — a multi-line EditBox
-- inside a ScrollFrame keeps the scroll child's rect in step as the text grows, and tracks
-- where the caret is so the OnUpdate can follow it.
--
-- Published under the REFERENCE's names because that is what an addon's XML wires: our
-- corpus hits it through `InviteOMaticOptionFormSpamPanelAIGSpamEditE...:OnTextChanged`,
-- which raised `attempt to call global 'ScrollingEdit_OnTextChanged'` the moment anyone
-- typed. Both take an OPTIONAL scroll frame and fall back to `this:GetParent()`, exactly as
-- the reference does — an addon that wires the bare name relies on that fallback.
--
-- `ScrollingEdit_OnUpdate`'s caret-follow scroll is NOT here: it needs the caret's own
-- offset from the font engine, which our EditBox does not report yet. Named rather than
-- stubbed silently — a stub that scrolls nowhere would look like a working caret-follow.
function ScrollingEdit_OnTextChanged(scrollFrame)
local f = scrollFrame or (this and this:GetParent())
if f and f.UpdateScrollChildRect then f:UpdateScrollChildRect() end
end
function ScrollingEdit_OnCursorChanged(x, y, w, h)
if not this then return end
this.cursorOffset = y
this.cursorHeight = h
end
function ScrollFrame_OnLoad()
getglobal(this:GetName() .. "ScrollBarScrollDownButton"):Disable()
getglobal(this:GetName() .. "ScrollBarScrollUpButton"):Disable()
local scrollBar = getglobal(this:GetName() .. "ScrollBar")
scrollBar:SetMinMaxValues(0, 0)
scrollBar:SetValueStep(1)
scrollBar:SetValue(0)
this.offset = 0
end
-- The frame's `<OnVerticalScroll>` body, as the reference writes it (UIPanelTemplates.lua
-- l.228-233, verbatim): the frame scrolled, so seat the bar at the new pixel value, round it
-- to a row, and repaint. `this`/`arg1` are the handler globals our dispatcher sets
-- (script/event.rs `invoke_with_globals`), so the ref's body runs unchanged.
--
-- Nothing in benilla's own FrameXML calls this — our six faux lists take the offset straight
-- off the Slider (BenillaFauxScrollBar_OnValueChanged above). It is here because it is the
-- shape 27 corpus addons write into their own XML, and it is reached through the
-- SetVerticalScroll passthrough there, whose one gap is documented at that spot.
function FauxScrollFrame_OnVerticalScroll(itemHeight, updateFunction)
local scrollbar = getglobal(this:GetName() .. "ScrollBar")
scrollbar:SetValue(arg1)
this.offset = floor((arg1 / itemHeight) + 0.5)
updateFunction()
end
-- The offset getters/setters the owner's list code reads (ref UIPanelTemplates.lua
-- l.235-241). _SetOffset diverges by one line, deliberately: the ref writes `frame.offset`
-- and leaves the thumb where it was, which is a latent desync until the next _Update; ours
-- syncs the bar, so a programmatic reset (top-of-list on open) moves it there and then.
function FauxScrollFrame_GetOffset(frame)
return frame.offset or 0
end
function FauxScrollFrame_SetOffset(frame, offset)
frame.offset = offset
local scrollBar = getglobal(frame:GetName() .. "ScrollBar")
scrollBar:SetValue(offset * scrollBar:GetValueStep())
end
-- Seat a trough ON ITS BAR. THE SOCKETS, and why this is not a free choice: the channel art
-- is not a plain tube — each end carries a 16-tall SOCKET that the arrow button drops into,
-- bounded by the frame's outer cap and an inner crossbar. Measured texel-exact off
@@ -223,84 +150,26 @@
end
end
-- Reconcile the bar to a freshly-sized list — the reference's own `FauxScrollFrame_Update`
-- (UIPanelTemplates.lua l.160-226), whose signature and return value are transcribed whole
-- because 28 corpus addons call it with all ten arguments and read the result:
-- **Our trough, following the REFERENCE's bar.** `BenillaScrollBar_SetShown` above is the
-- Benilla kit's own pairing and nothing in the reference's faux kit calls it — the
-- reference has no trough at all, which is exactly why this template stays ours (1837's
-- manifest note). Since the faux frames took the chain's `FauxScrollFrameTemplate` their
-- bar is `UIPanelScrollBarTemplate`, so an owner that wants its trough asks for it here,
-- right after its `FauxScrollFrame_Update`. Seating is idempotent and the visibility
-- simply mirrors the bar's, which the reference's `_Update` has already decided by
-- showing or hiding the scroll FRAME.
--
-- frame, numItems, numToDisplay, valueStep the list itself (our four)
-- button, smallWidth, bigWidth a `<name>1..numToDisplay` row family to
-- NARROW when the bar takes its gutter
-- highlightFrame, smallHighlightWidth, and the moving highlight over them
-- bigHighlightWidth
-- → showScrollBar 1 when the bar is up, nil when it is not
--
-- `valueStep` is what benilla called `itemHeight` before this landed; same number, the ref's
-- name. The shrink/widen tail is pure ref and no benilla window passes it — our lists are
-- laid out to their with-bar width from the start.
--
-- TWO DELIBERATE DIVERGENCES from the ref body, both named where they are:
--
-- 1 · The ref gates its whole middle section on `frame:IsVisible()` right after showing or
-- hiding the frame — reading back a state it just wrote. That is a quirk, not a
-- mechanism: it is true exactly when `showScrollBar` is, EXCEPT when an ancestor is
-- hidden, in which case the range/step/value silently do not get set and the bar is
-- stale the next time the window opens. benilla repaints lists while their window is
-- closed (a feed arriving is not a click), so we gate on `showScrollBar` — what the ref
-- means — and the offset clamp runs unconditionally.
-- 2 · The bar is shown/hidden through BenillaScrollBar_SetShown, not by the frame alone:
-- ours also carries the TROUGH (see its own note above; the ref has no trough in this
-- kit at all). The frame's own Show/Hide is kept as well, because it is what an addon
-- reads back — and because for us it is the same pixels either way, the bar being the
-- only thing a faux frame contains.
function FauxScrollFrame_Update(frame, numItems, numToDisplay, valueStep, button, smallWidth, bigWidth, highlightFrame, smallHighlightWidth, bigHighlightWidth)
local frameName = frame:GetName()
local scrollBar = getglobal(frameName .. "ScrollBar")
local maxOffset = numItems - numToDisplay
if maxOffset < 0 then maxOffset = 0 end
if not frame.offset or frame.offset > maxOffset then frame.offset = maxOffset end
if frame.offset < 0 then frame.offset = 0 end
local showScrollBar
if numItems > numToDisplay then showScrollBar = 1 end
scrollBar:SetValueStep(valueStep)
scrollBar:SetMinMaxValues(0, maxOffset * valueStep)
scrollBar:SetValue(frame.offset * valueStep)
BenillaScrollBar_UpdateArrows(scrollBar)
if showScrollBar then frame:Show() else frame:Hide() end
BenillaScrollBar_SetShown(scrollBar, showScrollBar)
-- The ref sizes the scroll child to the whole list (l.179-191) — which is what gives a
-- real ScrollFrame its scroll range. GUARDED, because our loader has no `<ScrollChild>`
-- element, so `$parentScrollChildFrame` exists only if the owner declared one itself.
-- (This is the same gap BenillaFauxScrollBar_OnValueChanged documents; closing it is a
-- loader change, not a FrameXML one.)
local scrollChildFrame = getglobal(frameName .. "ScrollChildFrame")
if scrollChildFrame then
if numItems > 0 then
scrollChildFrame:SetHeight(numItems * valueStep)
scrollChildFrame:Show()
else
scrollChildFrame:Hide()
end
end
-- Shrink because the bar is shown / widen because it is hidden (ref l.205-223).
if showScrollBar then
if highlightFrame then highlightFrame:SetWidth(smallHighlightWidth) end
if button then
for i = 1, numToDisplay do getglobal(button .. i):SetWidth(smallWidth) end
end
else
if highlightFrame then highlightFrame:SetWidth(bigHighlightWidth) end
if button then
for i = 1, numToDisplay do getglobal(button .. i):SetWidth(bigWidth) end
end
end
return showScrollBar
-- Decision 1860. Named after the bar rather than the frame because the trough is declared
-- `<barname>Trough` — one law, stated once, exactly as BenillaScrollTrough_Seat wants it.
function BenillaScrollTrough_Follow(scrollFrameName)
local sf = getglobal(scrollFrameName)
if not sf then return end
local bar = getglobal(scrollFrameName .. "ScrollBar")
if not bar then return end
local trough = getglobal(bar:GetName() .. "Trough")
if not trough then return end
BenillaScrollTrough_Seat(trough, bar)
if sf:IsShown() then trough:Show() else trough:Hide() end
end
-- A wheel spin over the list → one row (WoW convention: +1 up, −1 down). Called from the
@@ -525,23 +394,10 @@
</Layers>
</Frame>
<!--
The faux-scroll container: a plain `<Frame>` carrying the offset state + a right-hand scroll bar
(inset 16px top/bottom so the arrow buttons sit inside the track span — the ref
UIPanelScrollFrameTemplate's scrollbar anchors). An owner instantiates this and, in its own
OnLoad, sets `frame.updateFunc` to its list-repaint.
-->
<Frame name="FauxScrollFrameTemplate" virtual="true">
<Frames>
<Slider name="$parentScrollBar" inherits="BenillaScrollBarTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
</Slider>
</Frames>
<Scripts>
<OnLoad>ScrollFrame_OnLoad()</OnLoad>
</Scripts>
</Frame>
<!-- `FauxScrollFrameTemplate` and the `FauxScrollFrame_*`/`ScrollFrame_*`/`ScrollingEdit_*`
API are the REFERENCE's, off the player's chain (`Interface\FrameXML\UIPanelTemplates`,
seated below this file — so its declarations win). Our transcriptions stood here dead
from the day that entry landed and are gone (1860). What stays is
`BenillaScrollBarTemplate` + `BenillaScrollTroughTemplate` and their helpers, which are
ours: six windows inherit the bar directly and the reference has no trough at all. -->
</Ui>
+34 -7
View File
@@ -492,8 +492,8 @@
frame:RegisterEvent("TRAINER_UPDATE")
frame:RegisterEvent("TRAINER_CLOSED")
frame:RegisterEvent("PLAYER_MONEY")
-- The faux-scroll frame repaints the list through this window's Update.
getglobal("BenillaTrainerListScrollFrame").updateFunc = BenillaTrainerFrame_Update
-- The list repaint is wired in the scroll frame's own `<OnVerticalScroll>` below, the
-- reference's way; `updateFunc` was our kit's field and the reference has none (1860).
end
-- Push the three saved globals into the engine — the ref's ClassTrainerFrame <OnShow>
@@ -508,6 +508,14 @@
SetTrainerServiceTypeFilter("used", TRAINER_FILTER_USED)
end
-- The scroll frame's `<OnVerticalScroll>` lives in its own chunk and cannot see this
-- file's `local BENILLA_TRAINER_ROW_HEIGHT`, so the pitch is handed over here rather than
-- written as a literal in the XML (1860). The reference has the same shape with globals:
-- `QUESTLOG_QUEST_HEIGHT` and friends are file-scope globals in its own FrameXML.
function BenillaTrainerList_OnVerticalScroll()
FauxScrollFrame_OnVerticalScroll(BENILLA_TRAINER_ROW_HEIGHT, BenillaTrainerFrame_Update)
end
function BenillaTrainerFrame_SetTitle(name)
if name and name ~= "" then
getglobal("BenillaTrainerTitleText"):SetText(name)
@@ -518,6 +526,14 @@
function BenillaTrainerFrame_OnEvent(frame)
if event == "TRAINER_SHOW" then
-- SHOW FIRST, then populate — the reference's own order, and not a style choice.
-- `FauxScrollFrame_Update` (the chain's since 1837) gates everything that sets the
-- bar's range on `frame:IsVisible()`, so a list repainted before its window is up
-- leaves `SetMinMaxValues` unset and both arrows dead. The reference relies on this
-- order hard enough to test it: `MerchantFrame.lua:22-27` does `ShowUIPanel(this)`
-- and then treats `not this:IsVisible()` as the panel system refusing, and closes.
-- Decision 1860.
ShowUIPanel(frame)
-- Fresh session: top of the list, a first selection (ref ClassTrainerFrame_Show).
BenillaTrainerFrame_ApplyFilter()
BenillaTrainerFrame_SetTitle(arg1)
@@ -526,7 +542,6 @@
selectFirstService()
end
BenillaTrainerFrame_Update()
ShowUIPanel(frame)
elseif event == "TRAINER_UPDATE" then
BenillaTrainerFrame_SetTitle(arg1)
-- Re-anchor the selection if it fell off the (possibly shorter) list.
@@ -793,13 +808,25 @@
<Button name="BenillaTrainerService10" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService9" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaTrainerService11" inherits="BenillaTrainerServiceTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerService10" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- The faux-scroll frame (FauxScrollFrameTemplate, ScrollTemplates.xml): invisible
container over the 11 rows; only its right-hand Slider scroll bar renders. Sized to the
11-row span so the bar sits in the window's right gutter. updateFunc is set in
BenillaTrainerFrame_OnLoad. -->
<!-- The faux-scroll frame (`FauxScrollFrameTemplate`, the REFERENCE's since 1837):
invisible container over the 11 rows; only its right-hand Slider scroll bar renders.
Sized to the 11-row span so the bar sits in the window's right gutter.
THE OWNER DECLARES `<OnVerticalScroll>`, which is how the reference's kit works and
the one thing our transcription did differently: ours carried a `frame.updateFunc`
field that `BenillaFauxScrollBar_OnValueChanged` read, and the reference has no such
field. Its chain is the bar's own OnValueChanged
(`this:GetParent():SetVerticalScroll(arg1)`, UIPanelTemplates.xml) -> this handler ->
`FauxScrollFrame_OnVerticalScroll(itemHeight, updateFunction)`, which is the only
thing that writes `frame.offset`. Without it the arrows and the thumb move the bar
and the list never follows (1860). 16 is the row pitch
(`BenillaTrainerServiceTemplate`). -->
<ScrollFrame name="BenillaTrainerListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="176"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-100"/></Offset></Anchor></Anchors>
<Scripts>
<OnVerticalScroll>BenillaTrainerList_OnVerticalScroll()</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail spell icon (ref ClassTrainerSkillIcon): a 37×37 button over a 64×64 UI-EmptySlot
+12 -243
View File
@@ -2228,171 +2228,12 @@
tDeleteItem(PULSEBUTTONS, button)
end
-- ── The tab kit (ref UIPanelTemplates.lua l.1-29/90-133, transcribed VERBATIM shape) ──
-- One selected tab per group: the frame carries numTabs/selectedTab, tabs are named
-- <frame>Tab1..N (CharacterFrameTabButtonTemplate instances — the template lives in this
-- file). SelectTab swaps the InActive art for the Active art and disables the button (a
-- selected tab doesn't re-click); DeselectTab reverses it. Omitted vs ref: the
-- isDisabled/SetDisabledTabState arm (no window has a disabled tab yet) and the SelectTab
-- tab-tooltip hide (our tabs set no tooltip). First consumer: the merchant's
-- Merchant/Buyback pair.
-- The kit's XML-facing entry point (ref l.2-4), and the one member of it this client never
-- had. Not an omission anyone decided: our own windows wire each tab's OnClick to their own
-- handler (the merchant's Merchant/Buyback pair, CharacterFrame's pages), so nothing here
-- ever reached for the generic one. An ADDON does the opposite — it puts the reference's
-- name straight in its <OnClick> and lets the kit do the work:
--
-- Enchantrix EnchantrixBarker.xml:61 (Enchantrix_BarkerOptions_Frame)
-- Outfitter Outfitter.xml:7 (OutfitterFrame)
-- SimpleActionSets SimpleActionSets.xml:342 (this:GetParent())
-- TheoryCraft TheoryCraft.xml:163 (TheoryCraft)
--
-- Four addons, four separate files, so 1207's one-library caveat does not apply. A raw grep
-- says FIVE: QuestItemTemplates.xml:17 has the call commented out. Reading the line is what
-- tells those apart, and the survey's own count of 4 was the right one.
--
-- Missing, every one of them raises on each click of the tab, so the addon's options window
-- stays on whatever page it opened with and cannot be navigated at all. `this` is the
-- clicked tab button, whose id is its slot in the row; the frame is passed in separately
-- because the tab's parent is not always the frame owning the row — SimpleActionSets is the
-- case that proves it, passing `this:GetParent()` explicitly.
function PanelTemplates_Tab_OnClick(frame)
PanelTemplates_SetTab(frame, this:GetID())
end
function PanelTemplates_SetNumTabs(frame, numTabs)
frame.numTabs = numTabs
end
function PanelTemplates_SetTab(frame, id)
frame.selectedTab = id
PanelTemplates_UpdateTabs(frame)
end
function PanelTemplates_GetSelectedTab(frame)
return frame.selectedTab
end
function PanelTemplates_UpdateTabs(frame)
if not frame.selectedTab then return end
for i = 1, frame.numTabs do
local tab = getglobal(frame:GetName() .. "Tab" .. i)
if tab.isDisabled then
PanelTemplates_SetDisabledTabState(tab)
elseif i == frame.selectedTab then
PanelTemplates_SelectTab(tab)
else
PanelTemplates_DeselectTab(tab)
end
end
end
-- The disabled-tab arm (ref UIPanelTemplates.lua l.94-105 + l.135-148) — the piece this
-- kit deliberately omitted until a window needed it. First customer: the social window's
-- GUILD tab, which `InGuildCheck` disables for a character in no guild — i.e. permanently
-- until the guild arc exists (decision 0668). A disabled tab wears the INACTIVE
-- three-slice art (like an unselected tab) but is un-clickable with a greyed label, so it
-- reads as "there, not for you" rather than as the selected one.
function PanelTemplates_DisableTab(frame, index)
getglobal(frame:GetName() .. "Tab" .. index).isDisabled = 1
PanelTemplates_UpdateTabs(frame)
end
function PanelTemplates_EnableTab(frame, index)
local tab = getglobal(frame:GetName() .. "Tab" .. index)
tab.isDisabled = nil
tab:SetDisabledTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
PanelTemplates_UpdateTabs(frame)
end
function PanelTemplates_SetDisabledTabState(tab)
local name = tab:GetName()
getglobal(name .. "Left"):Show()
getglobal(name .. "Middle"):Show()
getglobal(name .. "Right"):Show()
tab:Disable()
tab.text = tab:GetText()
tab:SetDisabledTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
getglobal(name .. "LeftDisabled"):Hide()
getglobal(name .. "MiddleDisabled"):Hide()
getglobal(name .. "RightDisabled"):Hide()
end
function PanelTemplates_DeselectTab(tab)
local name = tab:GetName()
getglobal(name .. "Left"):Show()
getglobal(name .. "Middle"):Show()
getglobal(name .. "Right"):Show()
tab:Enable()
getglobal(name .. "LeftDisabled"):Hide()
getglobal(name .. "MiddleDisabled"):Hide()
getglobal(name .. "RightDisabled"):Hide()
end
function PanelTemplates_SelectTab(tab)
local name = tab:GetName()
getglobal(name .. "Left"):Hide()
getglobal(name .. "Middle"):Hide()
getglobal(name .. "Right"):Hide()
tab:Disable()
getglobal(name .. "LeftDisabled"):Show()
getglobal(name .. "MiddleDisabled"):Show()
getglobal(name .. "RightDisabled"):Show()
end
-- The text-fit (ref PanelTemplates_TabResize, UIPanelTemplates.lua l.31-88 — now the WHOLE
-- function, `(padding, tab, absoluteSize, maxWidth)`): the tab is text + padding wide, plus
-- the two end slices; both middle slices stretch to the text; the text box itself un-sizes
-- to natural. `maxWidth` CAPS it and pins the label's box to the cap instead — the arm that
-- keeps a long tab inside its window, which the first transcription dropped and the macro
-- window's character tab then overran (0983 → 0991). `absoluteSize` is the ref's third arm,
-- transcribed with it so the next caller has it.
--
-- One deviation: the ref reads the label's laid-out GetWidth (its C measure is synchronous);
-- ours reads GetStringWidth — the same number once the measure round-trip lands, which is
-- why the caller below re-checks from OnUpdate instead of OnShow.
function PanelTemplates_TabResize(padding, tab, absoluteSize, maxWidth)
-- The ref's own opening arm, and it is not defensive: `tab` is OPTIONAL, and a caller
-- that omits it is relying on `this`. Ours had only the explicit-`tab` half, so every
-- caller in our own files passed one and the gap stayed invisible — until a stock file
-- called `PanelTemplates_TabResize(0)` from a tab's OnLoad and threw
-- "attempt to index local 'tab' (a nil value)". `Blizzard_MacroUI.xml`'s two tabs do
-- exactly that; so do the readiness instrument's two `UiPanels.xml` failures.
-- Decision 1835.
if ( not tab ) then
tab = this
end
local name = tab:GetName()
local text = getglobal(name .. "Text")
local sideWidths = 2 * getglobal(name .. "Left"):GetWidth()
local width, tabWidth
if absoluteSize then
if absoluteSize < sideWidths then
width, tabWidth = 1, sideWidths
else
width, tabWidth = absoluteSize - sideWidths, absoluteSize
end
text:SetWidth(width)
else
width = text:GetStringWidth() + (padding or 24)
if maxWidth and width > maxWidth then
width = maxWidth + (padding or 24)
text:SetWidth(width)
else
text:SetWidth(0)
end
tabWidth = width + sideWidths
end
getglobal(name .. "Middle"):SetWidth(width)
getglobal(name .. "MiddleDisabled"):SetWidth(width)
tab:SetWidth(tabWidth)
-- benilla addition: hand back the width just declared. `tab:GetWidth()` cannot serve
-- it — that reads the last RESOLVED rect, which inside a settle is this frame's stale
-- input, not the value one line above just set. A clamp that read it back saw the old
-- width, declined to fire, and the settle's gate then locked the un-clamped result in
-- for good (decision 1002).
return tabWidth
end
-- The tab kit — `PanelTemplates_*` — is the REFERENCE's, off the player's chain
-- (`Interface\FrameXML\UIPanelTemplates.lua`, seated below this file in the manifest).
-- Our eleven transcribed copies stood here dead from the day that entry landed, and one
-- of them was carrying a benilla-only return the settle below read (1855/1860). What
-- stays is that settle, which is ours because our text measure is a frame late and the
-- reference's is not.
-- The tab template's per-frame settle: the ref does TabResize(0) once, in OnShow
-- (CharacterFrameTemplates.xml l.77-80), trusting a synchronous text measure. Our
@@ -2597,84 +2438,12 @@
</Scripts>
</Button>
<!--
The SMALL tab button (ref `TabButtonTemplate`, UIPanelTemplates.xml l.303-402, transcribed
VERBATIM: the HelpFrameTab Active/Inactive three-slice pair on 16/32/16 slices, text at
BOTTOM +5, the ADD highlight at +2,-8 — anchored to both side edges rather than sized in
Lua, the one departure, and why: see the highlight below). Not the same thing as
`CharacterFrameTabButtonTemplate` above — that one is the big *window* tab along a panel's
bottom edge; this is the compact tab a panel uses to switch a list INSIDE itself. The
social window's Friends/Ignore pair is the first customer (decision 0668); the reference
also uses it for the help frame and the raid pane.
Slice naming matches the big tab's, so the shared PanelTemplates_SelectTab/DeselectTab kit
drives both unchanged — "Disabled" is "selected" here too. Same OnUpdate text-fit for the
same reason (our GetStringWidth lands a frame late).
-->
<Button name="TabButtonTemplate" virtual="true">
<Size><AbsDimension x="115" y="32"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeftDisabled" file="Interface\HelpFrame\HelpFrameTab-Active">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddleDisabled" file="Interface\HelpFrame\HelpFrameTab-Active">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentLeftDisabled" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentRightDisabled" file="Interface\HelpFrame\HelpFrameTab-Active">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentMiddleDisabled" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentLeft" file="Interface\HelpFrame\HelpFrameTab-Inactive">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddle" file="Interface\HelpFrame\HelpFrameTab-Inactive">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentRight" file="Interface\HelpFrame\HelpFrameTab-Inactive">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText">
<Size><AbsDimension x="0" y="13"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="5"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<!-- The highlight spans the tab's own two side edges, so it tracks any fit for free. The
ref sizes it in Lua instead — `SetWidth(GetTextWidth() + 31)` at OnLoad, which on the
template's own +0 fit (tab = text + 32) lands at tab − 1: "the highlight is the tab",
said in arithmetic. It only stops meaning that when an instance passes a padding or a
cap, and the macro window's tabs pass both — which is how the director got a highlight
13 units proud of tab 1 and 35 short of tab 2 in the same screenshot (decision 1004).
The big tab template above already says it this way (LEFT/RIGHT, no Lua); this is the
same statement on 16-unit end slices. Two opposing anchors resolve the size from the
span and never consult the width, so the declared 5 is inert on x (wow-re layout.md,
VERIFIED); the y anchors are both BOTTOM, so 32 still sets the height. -->
<HighlightTexture name="$parentHighlightTexture" file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD">
<Size><AbsDimension x="5" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="2" y="-8"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="-8"/></Offset></Anchor>
</Anchors>
</HighlightTexture>
<Scripts>
<OnUpdate>BenillaTabButton_OnUpdate(self)</OnUpdate>
</Scripts>
</Button>
<!-- The SMALL tab button (`TabButtonTemplate`, the compact tab a panel uses to switch a list
INSIDE itself — the social window's Friends/Ignore pair, decision 0668) is the
REFERENCE's, off the chain: `Interface\FrameXML\UIPanelTemplates.xml` declares it and
our transcription here was overwritten from that day (1860). `FriendsFrame.xml` is its
only consumer and loads long after that entry, so it has been inheriting the reference's
since the swap. -->
<!--
The shared dialog instance — the ref's StaticPopup1 (decision 0308 §3; the engine lives in
+12 -3
View File
@@ -17,8 +17,16 @@ use super::spell_visual::SpellVisuals;
use super::{SpellKitFx, SwingImpact, SwingMessage};
/// The gore level: the client's `violenceLevel` cvar (0 none · 1 censored green · 2 true colors).
/// **Hardcoded to maximum by the director's directive** (decision 0137 phase 3) — a benilla config
/// default, not a fidelity claim; revisit when a settings surface exists.
///
/// **2 is the reference's own default, not merely ours** (1859; the rule is 1804). The
/// registration at `0x6c5aa0` *formats* its default out of a per-region **maximum** table at
/// `0x86c3f8` — `[2, 1, 2, 2, 2, 2, 2, 2]` — and the setter `0x6c5af0` clamps to that same entry,
/// so a stock client boots at the highest gore its region permits. The locale table at `0x8558a4`
/// is `{enUS, koKR, frFR, …}` and `[0xc0e080]` is BSS-zero, so **enUS defaults to 2**; the single
/// capped entry is koKR, which at 1 maps red → *green* through UnitBloodLevels' middle column —
/// the censored-locale green blood, which this chain produces without being fitted to it.
/// Hardcoded rather than carried as a CVar row because
/// `cvars::REGISTERED` takes one row per knob a settings page actually wires (0137 phase 3).
const VIOLENCE_LEVEL: usize = 2;
/// The victim-model M2 attachment ids the spurt hangs on (`melee-blood-spurt.md`: CEffect at
@@ -104,7 +112,8 @@ pub(super) fn blood_spurts(
continue;
};
// The reference's three-tier row resolve (`0x60afb0`), including the tier-3 records-base
// fallback that 595 of the 10534 shipped displays land on — see `level_key`.
// fallback that 595 of the 10534 shipped displays land on — Quilboar, crocolisks, gnolls
// and Stranglethorn trolls among them, which is why it cannot mean "no blood" (1859).
let Some(blood_id) = blood.0.level_key(disp_blood, model_blood) else {
info!("blood: dropped — UnitBloodLevels is empty");
continue;
+11
View File
@@ -267,6 +267,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
since Vista, so \"1\" would ship audio the real client has never actually produced \
(B236)",
),
// The mix-ahead depth (1857) — 1.12's own `SoundBufferSize` (`0x4571ca`, flags 2: latched,
// read once at sound-system init), "sound buffer size (milliseconds)": FMOD 3's mix-ahead
// buffer, the distance the software mixer runs ahead of the output device. benilla's own
// output has the same quantity — the render thread's ring ahead of the IO callback
// (`sound::output`) — so the reference's dial drives it, in the reference's unit. The
// registrar's default is a two-way host choice: `0x457520` returns "50" or "100" from an
// OS-version probe (strings at `0x835e10`/`0x835e0c`, byte-read 2026-09-02). Ours is the
// larger of its two, because the stall the crackle was measured from was a whole IO cycle
// long and the depth exists to hide the next one. Applies at the next launch, like the
// reference's.
same("SoundBufferSize", "100"),
// The output limiter (1551) — benilla's own, not a 1.12 CVar. The reference needs no such DSP
// (its mix is FMOD 3's and its headroom lives in the SFX-bus auto-duck); benilla sums into f32
// behind a hard clamp, and every WoW SFX is mastered to full scale, so two overlapping kits
+19
View File
@@ -82,6 +82,25 @@ pub(crate) struct DeathNet {
pub(crate) water_walk: bool,
}
impl DeathNet {
/// **Arm the spirit healer's XP-loss question** — the one entry point, because there are two
/// ways to arrive at it and they must not drift.
///
/// The reference raises this dialog **client-side, on the click**: `0x5f0130`'s bit-5 arm
/// (`0x5df730`) sends no packet at all — it caches the healer's guid and fires
/// `CONFIRM_XP_LOSS`, and `CMSG_SPIRIT_HEALER_ACTIVATE` belongs to the Lua Accept
/// (`AcceptXPLoss`), which calls the same function with a ZERO guid (wow-re
/// `interact-dead-fork-and-npc-service-ladder.md` §C). vmangos also *pushes* the question as
/// `SMSG_SPIRIT_HEALER_CONFIRM` off its gossip route, and benilla honours both roads in.
///
/// The generation bump is what makes a re-ask re-show a cancelled dialog: the confirm
/// announces per MESSAGE, not per `Some`-edge (decision 1068).
pub(crate) fn ask_spirit_healer(&mut self, npc: u64) {
self.spirit_healer = Some(npc);
self.confirm_generation = self.confirm_generation.wrapping_add(1);
}
}
/// The feed's memory, in **two scopes** (decisions 1290/1291): the body's own state machine is
/// world memory and survives a `/reload`; what this VM has been *told* dies with the VM.
///
+8
View File
@@ -31,6 +31,14 @@
// spent its whole diff clearing it out of. Dev builds still warn normally.
#![cfg_attr(not(feature = "dev"), allow(dead_code))]
/// The realtime-audio allocation tripwire (decision 1857). In debug builds an allocation inside
/// the output IO callback or the render pass (`sound::output`'s `no_alloc` scopes) aborts the
/// process, so a realtime-safety regression fails a test or a smoke instead of reaching an ear;
/// release builds carry no wrapper at all.
#[cfg(debug_assertions)]
#[global_allocator]
static ALLOC: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;
pub mod addon_harness;
mod area;
mod area_poi;
+4 -2
View File
@@ -129,8 +129,10 @@ pub(super) fn resurrect_request(
/// reference fires `CONFIRM_XP_LOSS` per arrival — so the generation bump is what re-shows a
/// cancelled confirm, exactly the `SMSG_CORPSE_RECLAIM_DELAY` re-fire pattern above.
pub(super) fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) {
death_net.spirit_healer = Some(npc);
death_net.confirm_generation = death_net.confirm_generation.wrapping_add(1);
// Through [`DeathNet::ask_spirit_healer`], which the right-click's own bit-5 arm also calls —
// the reference raises this dialog client-side and vmangos also pushes it, and one entry
// point is what keeps the two roads saying the same thing (decision 1861).
death_net.ask_spirit_healer(npc);
}
/// `SMSG_DURABILITY_DAMAGE_DEATH` — the 10% death durability loss.
+29 -1
View File
@@ -1,7 +1,12 @@
//! The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` to disable; macOS only): a watchdog
//! thread notices the main-thread heartbeat has gone stale and shells the stock profiler
//! (`/usr/bin/sample`) at our own PID, so an intermittent stall or teardown hang **diagnoses
//! itself the next time it happens** — on anyone's run, no reproduction needed. Two bugs that
//! itself the next time it happens** — on anyone's run, no reproduction needed. **Except while
//! an audio device is open** (decision 1857): `sample` suspends every thread of the task,
//! CoreAudio's realtime IO thread included, and each suspended cycle is a crackle — the one
//! the director heard on every desktop switch and world entry, because those are exactly the
//! ≥600 ms main-thread stalls this watchdog fires on. In-frame sampling now waits for a run
//! nobody is listening to; `WOW_STALL_SAMPLE=force` opts a sounding run back in, knowingly. Two bugs that
//! each lost their reproduction motivated it (decision 0713): the ~1 s silent frame stalls at
//! the BWL pin, and the on-close beachball the director had to force-quit — where even the probe
//! backstop's `process::exit(0)` can wedge, because libc `exit(3)` runs the same atexit teardown
@@ -134,6 +139,8 @@ fn arm_injectors(app: &mut App) {
fn watchdog(dir: Option<std::path::PathBuf>) {
let pid = std::process::id().to_string();
let force = std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "force");
let mut declined = false;
let mut taken = 0u32;
let mut last_sample_ms = 0u64;
let mut exit_seen_ms = 0u64;
@@ -175,6 +182,27 @@ fn watchdog(dir: Option<std::path::PathBuf>) {
// [`SAMPLE_GAP_MS`] of process uptime (caught by the 0713 injector runs).
&& (taken == 0 || now.saturating_sub(last_sample_ms) > SAMPLE_GAP_MS)
{
// **Never while the speakers are live** (decision 1857). `sample` suspends the whole
// task for each of its ~1000 snapshots — the CoreAudio IO thread and our realtime
// render thread included — and coreaudiod times the client out: every stall this
// watchdog sampled in a run with a device open was also a crackle ("client timeout"
// × 4 per manual sample, zero overloads across 25 hitches with the sampler off).
// 1232 cleared the sampler on a quiet-machine measurement of the hole; under real
// load the hole is the sample. The instrument keeps its whole job on the runs that
// cannot be heard (captures, probes, `WOW_NOSOUND` — no device is open there) and
// at teardown, where the wedge outranks the last half-second of sound.
// `WOW_STALL_SAMPLE=force` overrides for a deliberate diagnostic run with sound.
if !exiting && crate::sound::output::device_open() && !force {
if !declined {
eprintln!(
"stall-sample: main thread stale {age} ms — NOT sampled: an audio \
device is open, and `sample` suspending the process is itself a \
crackle (1857). `WOW_STALL_SAMPLE=force` to sample anyway."
);
declined = true;
}
continue;
}
taken += 1;
last_sample_ms = now;
let _ = std::fs::create_dir_all(dir);
@@ -1,174 +0,0 @@
//! The HAL overload meter — the crackle detector that lives *downstream* of our mix
//! (decision 1114).
//!
//! The 1112 corner: the director's crackle is in none of our audio — the tap capture is clean
//! by scan and by their own ear, the callback met every deadline, the decoder never starved.
//! What remains is the one hop no in-process meter watched: CoreAudio's **IO cycle** — the HAL
//! collecting every client's buffer and handing the result to the hardware DMA on a hard
//! deadline. When that cycle runs late (a late thread wakeup, a memory-bandwidth storm from a
//! GPU upload burst — a first login's world entry is exactly that), the device plays stale or
//! zero samples and the speaker crackles, while the producing app measures itself healthy: the
//! mix was computed fast and correct, just consumed late. kira's load meter cannot see it
//! (it times `process()`, not the cycle), and macOS does not persist it to the unified log.
//!
//! CoreAudio *does* announce it to clients: the `kAudioDeviceProcessorOverload` property fires
//! a listener on every overloaded cycle. This module registers that listener on the default
//! output device and a Bevy system reports new overloads once per window — so the next live
//! crackle either stamps a `HAL overload` WARN at the moment the ear hears it (confirming the
//! downstream mechanism, and pointing at the device-buffer dial, 1026's residue), or stays
//! silent and rules this hop out too.
//!
//! macOS only by construction; every other platform compiles to a no-op. The listener sticks
//! to the device that was default at registration — a mid-session output swap loses the meter
//! (accepted: kira's cpal backend deliberately never re-queries devices either, 1026).
use bevy::prelude::*;
#[cfg(target_os = "macos")]
mod sys {
use std::sync::atomic::{AtomicU64, Ordering};
/// Overloaded-cycle count since launch, bumped by the listener (CoreAudio calls it on an
/// internal thread; the poller reads it on the main thread).
pub(super) static OVERLOADS: AtomicU64 = AtomicU64::new(0);
// The tiny slice of the CoreAudio C API this needs — declared here like `thread_qos` does
// for pthread QoS, so no version-pinned -sys crate enters the graph for four constants.
type OSStatus = i32;
type AudioObjectID = u32;
#[repr(C)]
struct AudioObjectPropertyAddress {
selector: u32,
scope: u32,
element: u32,
}
/// `kAudioObjectSystemObject`
const SYSTEM_OBJECT: AudioObjectID = 1;
/// `kAudioHardwarePropertyDefaultOutputDevice` — 'dOut'
const DEFAULT_OUTPUT: u32 = u32::from_be_bytes(*b"dOut");
/// `kAudioDeviceProcessorOverload` — 'over'
const PROCESSOR_OVERLOAD: u32 = u32::from_be_bytes(*b"over");
/// `kAudioObjectPropertyScopeGlobal` — 'glob'
const SCOPE_GLOBAL: u32 = u32::from_be_bytes(*b"glob");
/// `kAudioObjectPropertyElementMain`
const ELEMENT_MAIN: u32 = 0;
#[link(name = "CoreAudio", kind = "framework")]
unsafe extern "C" {
fn AudioObjectGetPropertyData(
object: AudioObjectID,
address: *const AudioObjectPropertyAddress,
qualifier_size: u32,
qualifier: *const core::ffi::c_void,
size: *mut u32,
data: *mut core::ffi::c_void,
) -> OSStatus;
fn AudioObjectAddPropertyListener(
object: AudioObjectID,
address: *const AudioObjectPropertyAddress,
listener: extern "C" fn(
AudioObjectID,
u32,
*const AudioObjectPropertyAddress,
*mut core::ffi::c_void,
) -> OSStatus,
client_data: *mut core::ffi::c_void,
) -> OSStatus;
}
extern "C" fn on_overload(
_object: AudioObjectID,
_n: u32,
_addresses: *const AudioObjectPropertyAddress,
_client: *mut core::ffi::c_void,
) -> OSStatus {
OVERLOADS.fetch_add(1, Ordering::Relaxed);
0
}
/// Register the overload listener on the current default output device. `Err` carries the
/// failing call for the log; a machine without a device returns quietly.
pub(super) fn register() -> Result<(), &'static str> {
let default_addr = AudioObjectPropertyAddress {
selector: DEFAULT_OUTPUT,
scope: SCOPE_GLOBAL,
element: ELEMENT_MAIN,
};
let mut device: AudioObjectID = 0;
let mut size = std::mem::size_of::<AudioObjectID>() as u32;
let status = unsafe {
AudioObjectGetPropertyData(
SYSTEM_OBJECT,
&default_addr,
0,
core::ptr::null(),
&mut size,
(&mut device as *mut AudioObjectID).cast(),
)
};
if status != 0 || device == 0 {
return Err("default output device lookup failed");
}
let overload_addr = AudioObjectPropertyAddress {
selector: PROCESSOR_OVERLOAD,
scope: SCOPE_GLOBAL,
element: ELEMENT_MAIN,
};
let status = unsafe {
AudioObjectAddPropertyListener(
device,
&overload_addr,
on_overload,
core::ptr::null_mut(),
)
};
if status != 0 {
return Err("overload listener registration failed");
}
Ok(())
}
pub(super) fn count() -> u64 {
OVERLOADS.load(Ordering::Relaxed)
}
}
/// Register the listener at startup (macOS with a live mixer only — silent runs have no device
/// stream to overload).
pub(super) fn setup(out: NonSend<super::SoundOutput>) {
if out.mixer.is_none() {
return;
}
#[cfg(target_os = "macos")]
match sys::register() {
Ok(()) => info!("audio: HAL overload listener armed on the default output device"),
Err(what) => warn!("audio: HAL overload listener unavailable — {what}"),
}
}
/// Report new overloads, rate-limited to one line per second — each one is a device cycle the
/// OS glitched *after* our (clean) mix, so a line landing at the moment a crackle is heard is
/// the downstream confirmation 1114 exists to catch.
pub(super) fn poll(time: Res<Time>, mut last: Local<(u64, f32)>) {
#[cfg(not(target_os = "macos"))]
{
let _ = (time, last);
}
#[cfg(target_os = "macos")]
{
let now = time.elapsed_secs();
let count = sys::count();
let (reported, last_line_at) = *last;
if count > reported && now - last_line_at >= 1.0 {
warn!(
"audio: {} HAL processor overload(s) — the OS missed the device cycle's hardware \
deadline DOWNSTREAM of our mix; the speaker glitched even though the mix is \
clean. This is the 1112 crackle signature.",
count - reported,
);
*last = (count, now);
}
}
}
+84 -80
View File
@@ -21,18 +21,20 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use bevy::prelude::*;
use cpal::traits::{DeviceTrait, HostTrait};
use kira::backend::cpal::CpalBackendSettings;
use kira::effect::reverb::{ReverbBuilder, ReverbHandle};
use kira::effect::volume_control::{VolumeControlBuilder, VolumeControlHandle};
use kira::listener::ListenerHandle;
use kira::sound::streaming::StreamingSoundData;
use kira::sound::FromFileError;
use kira::track::{SendTrackBuilder, SendTrackHandle, SpatialTrackBuilder};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Mix, Tween};
use kira::{AudioManager, AudioManagerSettings, Decibels, Mix, Tween};
use super::limiter;
use super::meter::{self, LevelReading, MixLevel};
use super::output::{self, Event, OutputBackend, OutputSettings, Window};
/// The output backend's per-window meters, re-exported for the report in `sound::poll_mix_health`.
pub(super) type OutputWindow = Window;
pub(crate) use benilla_formats::SoundProvider;
@@ -125,10 +127,12 @@ pub(crate) fn amp_to_db(amp: f32) -> Decibels {
/// wow-re `benilla-pins.md` B6); underwater is the reverb preset + the ambience swap, both
/// upstream of this seam.
pub(crate) struct Mixer {
manager: AudioManager<DefaultBackend>,
manager: AudioManager<OutputBackend>,
listener: ListenerHandle,
/// Rolling mix-health counters — the crackle instrument ([`Mixer::poll_health`]).
health: MixHealth,
/// The output meters accumulated since the last [`Mixer::take_output_window`].
window: Window,
/// The zone-reverb send track (wet-only Freeverb). Every 3D world track routes into it at
/// build; this handle's volume is the zone wet level (SILENCE = reverb off).
reverb_send: SendTrackHandle,
@@ -156,7 +160,14 @@ impl Mixer {
/// Open the default audio device. Fails cleanly when there is none (headless/CI) — the caller
/// runs silent with `None` (mirrors the client's `-nosound` gate).
pub(crate) fn new(probe_dir: Option<&Path>) -> Result<Self> {
let (backend_settings, sample_rate) = backend_settings();
// The device's rate is needed before the manager exists: the mix tap's WAV header and
// the limiter's delay line are sized at build time, and a kira main track is
// build-time-only.
let sample_rate = output::probe_sample_rate();
let backend_settings = OutputSettings {
mix_ahead_ms: mix_ahead_ms(),
device_buffer_frames: io_buffer_frames(),
};
let level = Arc::new(MixLevel::default());
let limiter_on = Arc::new(AtomicBool::new(true));
let MainChain {
@@ -165,7 +176,7 @@ impl Mixer {
output,
audio_pos,
} = main_track(&level, &limiter_on, sample_rate, probe_dir);
let settings = AudioManagerSettings::<DefaultBackend> {
let settings = AudioManagerSettings::<OutputBackend> {
backend_settings,
main_track_builder,
capacities: kira::Capacities {
@@ -174,8 +185,9 @@ impl Mixer {
},
..Default::default()
};
let mut manager = AudioManager::<DefaultBackend>::new(settings)
.map_err(|e| anyhow::anyhow!("audio device init: {e}"))?;
let mut manager = AudioManager::<OutputBackend>::new(settings)
.map_err(|e| anyhow::anyhow!("audio device init: {e:#}"))?;
let sample_rate = Some(manager.backend_mut().sample_rate());
// The zone-reverb send: wet-only (the dry path stays on the source tracks), silent until
// a zone preset raises it. Effects are build-time-only; parameters retune at runtime.
let mut send_builder = SendTrackBuilder::new().volume(Decibels::SILENCE);
@@ -209,6 +221,7 @@ impl Mixer {
manager,
listener,
health: MixHealth::default(),
window: Window::default(),
reverb_send,
reverb,
level,
@@ -495,79 +508,48 @@ struct MainChain {
audio_pos: Option<Arc<AtomicU64>>,
}
/// kira's cpal backend takes `device.default_output_config().config()` when handed no config of
/// its own, and that carries `BufferSize::Default` — whatever CoreAudio happens to hand us. On
/// macOS the buffer size is a *shared, per-device* property, so another app (an OBS capture, a
/// conferencing tool) can drag it down under us and we would never know. Naming it puts a floor
/// under the callback's deadline instead of inheriting someone else's.
///
/// 2048 frames is ~43 ms at 48 kHz. Latency that size is inaudible for WoW — nothing here is
/// rhythm-critical, the shortest UI click is an order of magnitude longer. The size is set by
/// the *HAL's* deadline, not ours: a confirmed crackle (decision 1115) was the OS missing the
/// device IO-cycle deadline under system pressure while our mix ran clean, and the buffer size
/// is the cycle length — fewer, longer cycles mean twice the slack per cycle for the HAL. Our
/// own mix cost was never the constraint at 1024 (~21 ms, decision 1026) and is even less so
/// here.
const TARGET_BUFFER_FRAMES: u32 = 2048;
/// Build the cpal backend settings: kira's default device, our explicit buffer size. Also
/// reports the negotiated sample rate (the mix tap's WAV header must match the renderer);
/// `None` when the device/config could not be probed — those paths run on kira defaults.
///
/// `device` stays `None` on purpose — that keeps kira's own default-device selection *and* its
/// disconnect/restart handling (`custom_device = false`). We override only the config. Every
/// failure path falls back to kira's defaults, so a machine we can't probe still opens the device
/// exactly as before; [`Mixer::new`]'s caller already tolerates no-device.
fn backend_settings() -> (CpalBackendSettings, Option<u32>) {
let fallback = CpalBackendSettings::default();
let Some(device) = cpal::default_host().default_output_device() else {
return (fallback, None);
};
let Ok(supported) = device.default_output_config() else {
return (fallback, None);
};
// Clamp into what the device will actually accept; `Unknown` means cpal can't tell us the
// range, and asking for a size outside it fails the stream build — so leave those alone.
let cpal::SupportedBufferSize::Range { min, max } = supported.buffer_size() else {
info!("audio: device reports no buffer-size range — leaving it to the driver");
let rate = supported.config().sample_rate;
return (fallback, Some(rate));
};
let frames = TARGET_BUFFER_FRAMES.clamp(*min, *max);
let mut config = supported.config();
config.buffer_size = cpal::BufferSize::Fixed(frames);
info!(
"audio: {} Hz, {} ch, buffer {frames} frames (~{:.1} ms)",
config.sample_rate,
config.channels,
f64::from(frames) / f64::from(config.sample_rate) * 1000.0,
);
let rate = config.sample_rate;
(
CpalBackendSettings {
config: Some(config),
..fallback
},
Some(rate),
)
/// `SoundBufferSize` (decision 1857): the reference's latched mix-ahead dial, read once here
/// the way the reference reads it once at sound-system init — before the `App` exists, so
/// straight from the stored config (`boot_cvar`), the registered default when unset. Clamped
/// to what a ring can sensibly hold; a value outside it is a typo, not a request.
fn mix_ahead_ms() -> u32 {
crate::cvars::boot_cvar("SoundBufferSize")
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|ms| ms.is_finite())
.map_or(output::MIX_AHEAD_MS, |ms| {
ms.round().clamp(10.0, 1000.0) as u32
})
}
/// The mix-health counters — what a crackle actually *is*, in numbers (decision 1026).
/// `$WOW_IO_BUFFER` — the device IO buffer in frames, the A/B arm 1857 leaves open: whether a
/// busier IO thread (512, the shipping-engine norm) or a longer cycle budget (1115's 2048)
/// rides a Space switch better is a question this machine's next crackle answers, and both
/// arms have to be one env var apart to answer it. Unset is the default in `sound::output`.
fn io_buffer_frames() -> u32 {
std::env::var("WOW_IO_BUFFER")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|&n| n > 0)
.unwrap_or(output::DEVICE_BUFFER_FRAMES)
}
/// The mix-health counters — what a crackle actually *is*, in numbers (decision 1026; the
/// meters behind them moved into the owned backend in 1857).
///
/// Before this, a crackle was invisible to us: the only report was the director's ear, and we
/// could not tell a missed callback deadline from a stepped parameter from a starved decoder.
/// kira hands us the measurement and we were not reading it.
#[derive(Default, Clone, Copy, Debug)]
pub(crate) struct MixHealth {
/// The most recent callback load: elapsed / allotted, where allotted is `frames / sample_rate`
/// (kira's `pop_cpu_usage`). **`>= 1.0` means the mix missed its deadline** — that is an
/// underrun, and an underrun is audible as a crack.
/// The most recent render pass's load: wall time / the chunk's own duration. The render
/// thread mixes ahead of the device, so `>= 1.0` here is not yet audible — it is the thread
/// losing ground; the audible failure is [`Self::overruns`].
pub(crate) load: f32,
/// Worst load seen since the last [`Mixer::take_health_peak`].
pub(crate) peak_load: f32,
/// Callbacks at/over the deadline, since launch.
/// Device cycles the mix-ahead ring could not fill, since launch — each one went out as
/// silence. This is the underrun, the one failure here that is audible by definition.
pub(crate) overruns: u64,
/// cpal stream errors kira handled, since launch (device loss, `BufferUnderrun`).
/// Stream-side failures since launch (a lost device, a refused open).
pub(crate) stream_errors: u64,
/// 3D plays refused because the spatial-voice arena was full ([`SPATIAL_VOICE_CAPACITY`]),
/// since launch. Every one of these is a sound the player should have heard and did not.
@@ -575,21 +557,38 @@ pub(crate) struct MixHealth {
}
impl Mixer {
/// Drain the backend's health queues. Cheap and non-blocking (two ring-buffer pops per
/// frame); the queues are bounded, so *not* draining them is what loses information.
/// Service the output backend — device notices, stream rebuilds, the meters — and fold the
/// window into the health counters. Cheap and non-blocking; call every frame.
pub(crate) fn poll_health(&mut self) -> MixHealth {
let backend = self.manager.backend_mut();
while let Some(load) = backend.pop_cpu_usage() {
self.health.load = load;
self.health.peak_load = self.health.peak_load.max(load);
if load >= 1.0 {
self.health.overruns += 1;
let (window, events) = backend.service();
self.sample_rate = Some(backend.sample_rate());
self.health.stream_errors = backend.stream_errors();
for event in events {
match event {
Event::Opened {
device,
sample_rate,
buffer_frames,
mix_ahead_ms,
realtime_latency_frames,
} => info!(
"audio: {device} — {sample_rate} Hz, IO buffer {buffer_frames} frames \
(~{:.1} ms), mix-ahead {mix_ahead_ms} ms, device latency ~{:.1} ms",
f64::from(buffer_frames) / f64::from(sample_rate) * 1000.0,
f64::from(realtime_latency_frames) / f64::from(sample_rate) * 1000.0,
),
Event::Lost(why) => warn!("audio: output stream dropped — {why}; reopening"),
Event::OpenFailed(what) => warn!("audio: output device refused — {what}; retrying"),
Event::Dead(what) => error!("audio: output is gone for good — {what}"),
}
}
while let Some(err) = backend.pop_error() {
self.health.stream_errors += 1;
warn!("audio: stream error — {err}");
if window.render_chunks > 0 && window.chunk_ms > 0.0 {
self.health.load = (window.render_wall_max_ms / window.chunk_ms) as f32;
self.health.peak_load = self.health.peak_load.max(self.health.load);
}
self.health.overruns += window.underruns;
self.window.merge(window);
self.health
}
@@ -597,6 +596,11 @@ impl Mixer {
pub(crate) fn take_health_peak(&mut self) -> f32 {
std::mem::take(&mut self.health.peak_load)
}
/// The output meters accumulated since the last take — the report's raw material.
pub(crate) fn take_output_window(&mut self) -> Window {
std::mem::take(&mut self.window)
}
}
/// Move a live spatial track's emitter — the tracked-channel follow (the client's `0x61fec0`
+57 -16
View File
@@ -24,7 +24,6 @@ pub(crate) mod footsteps;
mod gameobject;
mod glue;
mod greeting;
mod hal_overload;
pub(crate) mod interior;
mod kit;
mod limiter;
@@ -37,6 +36,9 @@ mod mix_tap;
mod mixer;
mod money;
mod mount;
// Crate-visible for one reader: the dev-only stall watchdog asks `output::device_open` before
// it suspends the process (decision 1857). Dev may see anything; nothing here knows dev exists.
pub(crate) mod output;
mod probe;
mod reverb;
mod sheathe;
@@ -511,7 +513,6 @@ impl Plugin for SoundPlugin {
})
.init_resource::<SoundConfig>()
.init_resource::<AudioListener>()
.add_systems(Startup, hal_overload::setup)
// `Material.dbc` — shared by the foley and the melee impact, so it loads here rather
// than inside either consumer.
.add_systems(
@@ -535,7 +536,6 @@ impl Plugin for SoundPlugin {
apply_master_volume.after(toggle_mute),
apply_focus_gate,
poll_mix_health,
hal_overload::poll,
),
);
probe::plugin(app);
@@ -671,7 +671,6 @@ fn poll_mix_health(
time: Res<Time>,
mut exit: MessageReader<bevy::app::AppExit>,
mut since_report: Local<std::time::Duration>,
mut last_overruns: Local<u64>,
mut last_refused: Local<u64>,
mut peak_voices: Local<usize>,
) {
@@ -700,19 +699,9 @@ fn poll_mix_health(
let peak = mixer.take_health_peak();
let level = mixer.take_level();
let rate = mixer.sample_rate();
let window = mixer.take_output_window();
let voices = std::mem::take(&mut *peak_voices);
let new_overruns = health.overruns - *last_overruns;
*last_overruns = health.overruns;
if new_overruns > 0 {
warn!(
"audio: {new_overruns} missed mix deadline(s) in the last {}s (peak load {:.0}% of \
budget) — this is what a crackle sounds like",
MIX_HEALTH_REPORT.as_secs(),
peak * 100.0,
);
} else {
debug!("audio: mix load peak {:.0}% of budget", peak * 100.0);
}
report_output(window, peak);
let new_refused = health.voices_refused - *last_refused;
*last_refused = health.voices_refused;
if new_refused > 0 {
@@ -730,6 +719,58 @@ fn poll_mix_health(
/// and without the limiter kira's `clamp` would have squared it off. The line names what the game
/// asked for, how long it was over, what the limiter had to pull, and how many voices were live —
/// which together say *why* (thirty voices at once is a different bug from one voice at 4×).
/// The output-side story of one report window (decision 1857): every layer between the mix
/// and the speaker, each with its own number, so a crackle names its layer here.
fn report_output(w: mixer::OutputWindow, peak_load: f32) {
if w.cycles == 0 {
// No stream ran this window; `Mixer::poll_health` already said why, per event.
return;
}
let lead = w
.lead_min_ms
.map_or_else(|| "n/a".to_string(), |v| format!("{v:.1} ms"));
let ring = w
.ring_min_ms
.map_or_else(|| "n/a".to_string(), |v| format!("{v:.0} ms"));
let shape = format!(
"{} cycles: lead ≥ {lead}, IO copy ≤ {:.2} ms, cycle gap ≤ {:.1} ms (nominal {:.1}), \
render ≤ {:.2} ms per {:.1} ms chunk (peak load {:.0}%), ring ≥ {ring}",
w.cycles,
w.io_wall_max_ms,
w.gap_max_ms,
w.cycle_ms,
w.render_wall_max_ms,
w.chunk_ms,
peak_load * 100.0,
);
if w.underruns > 0 {
warn!(
"audio: {} output underrun(s) — ~{:.0} ms of silence reached the device: the render \
thread fell the whole mix-ahead behind. This is what a crackle sounds like. {shape}",
w.underruns, w.underrun_ms,
);
}
if w.overloads > 0 {
let ago = w
.last_overload_ago_ms
.map_or_else(String::new, |v| format!(", last {v:.0} ms ago"));
warn!(
"audio: {} HAL processor overload(s){ago} — the OS says our IO cycle ran past its \
deadline. The copy's own wall time and the lead say which: a long copy is the IO \
thread parked inside the callback (page-in, preemption), a short lead is it woken \
late. {shape}",
w.overloads,
);
} else if w.lead_min_ms.is_some_and(|l| l < 0.0)
|| w.io_wall_max_ms > w.cycle_ms * 0.5
|| w.gap_max_ms > w.cycle_ms * 1.5
{
warn!("audio: IO cycle strain without an overload — {shape}");
} else {
debug!("audio: output steady — {shape}");
}
}
fn report_level(level: meter::LevelReading, voices: usize, rate: Option<u32>) {
// Ahead of the level story on purpose: a non-finite sample is not a loud mix, it is a broken
// one, and it is invisible to every other counter we have — including the limiter's own
@@ -0,0 +1,601 @@
//! The CoreAudio half of [`super`] — the thinnest correct layer between the mixer and the
//! device (decision 1857).
//!
//! What lives here and nowhere else: finding the default output device and reading what it
//! runs at; opening a HAL output unit pinned to that device with our stream format and our
//! per-client IO buffer; the four property listeners the backend reacts to; the realtime
//! scheduling of the render thread (time-constraint policy + the device's audio workgroup,
//! Apple's documented pair for an audio worker thread); and the host clock the meters read.
//!
//! Everything is plain C API through the objc2 CoreAudio bindings and `coreaudio-rs`'s
//! `AudioUnit` — the two crates cpal itself stood on, now used directly, because the cpal layer
//! between them and us was where three of the defects 1857 lists lived (a no-op error callback
//! on the default device, a `DefaultOutput` unit pinned to a fixed device, and the IO-cycle
//! timestamp thrown away before it reached anyone).
use std::ffi::c_void;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::{AudioUnit, Element, IOType, SampleFormat, Scope, StreamFormat};
use objc2_core_audio::{
kAudioDeviceProcessorOverload, kAudioDevicePropertyBufferFrameSize,
kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyDeviceIsAlive,
kAudioDevicePropertyIOThreadOSWorkgroup, kAudioDevicePropertyLatency,
kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertySafetyOffset,
kAudioDevicePropertyScopeOutput, kAudioHardwarePropertyDefaultOutputDevice,
kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject,
AudioObjectAddPropertyListener, AudioObjectGetPropertyData, AudioObjectID,
AudioObjectPropertyAddress, AudioObjectPropertySelector, AudioObjectRemovePropertyListener,
AudioObjectSetPropertyData,
};
use objc2_core_audio_types::AudioValueRange;
/// The channel count we render. kira mixes stereo; the HAL output unit maps our two channels
/// onto whatever the device has.
pub(super) const CHANNELS: u32 = 2;
/// `kAudioOutputUnitProperty_CurrentDevice` — pins a HAL output unit to one device.
const OUTPUT_UNIT_CURRENT_DEVICE: u32 = 2000;
/// `kAudioUnitProperty_MaximumFramesPerSlice` — the most frames one render call may ask for.
/// An output unit defaults to 1156; a device buffer larger than that must raise it or the
/// unit refuses the slice (`kAudioUnitErr_TooManyFramesToProcess`).
const UNIT_MAXIMUM_FRAMES_PER_SLICE: u32 = 14;
// ---------------------------------------------------------------------------------------------
// Host clock
// The two mach clock calls, declared here: libc deprecates its copies in favour of a crate we
// have no other use for, and two signatures are not worth a dependency.
#[repr(C)]
struct MachTimebaseInfo {
numer: u32,
denom: u32,
}
extern "C" {
fn mach_absolute_time() -> u64;
fn mach_timebase_info(info: *mut MachTimebaseInfo) -> i32;
}
/// Nanoseconds on the host clock (`mach_absolute_time`, the clock CoreAudio stamps its IO
/// cycles with). Wall-monotonic, cheap (a commpage read), safe on the audio thread.
pub(super) fn now_ns() -> u64 {
// SAFETY: no arguments, no side effects.
host_ticks_to_ns(unsafe { mach_absolute_time() })
}
/// Convert a host-clock tick count (an `AudioTimeStamp::mHostTime`) to nanoseconds.
pub(super) fn host_ticks_to_ns(ticks: u64) -> u64 {
let (numer, denom) = timebase();
// u128 so a 5-day uptime × numer can't overflow.
(u128::from(ticks) * u128::from(numer) / u128::from(denom)) as u64
}
fn ns_to_host_ticks(ns: u64) -> u64 {
let (numer, denom) = timebase();
(u128::from(ns) * u128::from(denom) / u128::from(numer)) as u64
}
/// The mach timebase, read once. The ratio is a hardware constant; re-fetching it per
/// callback (as cpal did) is gratuitous work on the realtime path.
fn timebase() -> (u32, u32) {
static TIMEBASE: std::sync::OnceLock<(u32, u32)> = std::sync::OnceLock::new();
*TIMEBASE.get_or_init(|| {
let mut info = MachTimebaseInfo { numer: 0, denom: 0 };
// SAFETY: plain out-pointer call; a failure leaves zeros, which we guard.
let rc = unsafe { mach_timebase_info(&mut info) };
if rc != 0 || info.denom == 0 {
(1, 1)
} else {
(info.numer, info.denom)
}
})
}
// ---------------------------------------------------------------------------------------------
// Device
/// One output device as CoreAudio reports it at open time.
#[derive(Clone, Debug)]
pub(super) struct Device {
pub id: AudioObjectID,
pub name: String,
/// The device's nominal rate — what we render at (the unit does no rate conversion).
pub sample_rate: u32,
/// The device's accepted IO buffer range, frames.
pub buffer_range: (u32, u32),
/// The device's own output latency, frames (`kAudioDevicePropertyLatency`), for the report.
pub latency_frames: u32,
/// The safety offset, frames — the HAL's margin before the DMA that an IOProc must clear.
pub safety_frames: u32,
}
fn addr(selector: AudioObjectPropertySelector, scope: u32) -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress {
mSelector: selector,
mScope: scope,
mElement: kAudioObjectPropertyElementMain,
}
}
/// Read one fixed-size property. `T` must be the exact C type the selector carries.
fn get<T: Copy>(
object: AudioObjectID,
selector: AudioObjectPropertySelector,
scope: u32,
) -> Result<T> {
let address = addr(selector, scope);
let mut value = std::mem::MaybeUninit::<T>::uninit();
let mut size = std::mem::size_of::<T>() as u32;
// SAFETY: `size` bounds the write into `value`; the selector's type is `T` by contract of
// each call site below.
let status = unsafe {
AudioObjectGetPropertyData(
object,
NonNull::from(&address),
0,
std::ptr::null(),
NonNull::from(&mut size),
NonNull::new_unchecked(value.as_mut_ptr().cast::<c_void>()),
)
};
if status != 0 {
bail!(
"property {} read failed (OSStatus {status})",
fourcc(selector)
);
}
// SAFETY: a zero status means the HAL filled `size` bytes of `T`.
Ok(unsafe { value.assume_init() })
}
fn set<T: Copy>(
object: AudioObjectID,
selector: AudioObjectPropertySelector,
scope: u32,
value: &T,
) -> Result<()> {
let address = addr(selector, scope);
// SAFETY: `value` outlives the call and its size is passed.
let status = unsafe {
AudioObjectSetPropertyData(
object,
NonNull::from(&address),
0,
std::ptr::null(),
std::mem::size_of::<T>() as u32,
NonNull::from(value).cast::<c_void>(),
)
};
if status != 0 {
bail!(
"property {} write failed (OSStatus {status})",
fourcc(selector)
);
}
Ok(())
}
fn fourcc(selector: u32) -> String {
selector
.to_be_bytes()
.iter()
.map(|&b| if b.is_ascii_graphic() { b as char } else { '?' })
.collect()
}
/// The system's current default output device, fully described. `Err` when there is none
/// (headless CI, everything unplugged) — the caller runs silent.
pub(super) fn default_output() -> Result<Device> {
let id: AudioObjectID = get(
kAudioObjectSystemObject as AudioObjectID,
kAudioHardwarePropertyDefaultOutputDevice,
kAudioObjectPropertyScopeGlobal,
)
.context("no default output device")?;
if id == 0 {
bail!("no default output device");
}
describe(id)
}
fn describe(id: AudioObjectID) -> Result<Device> {
let name = coreaudio::audio_unit::macos_helpers::get_device_name(id)
.unwrap_or_else(|_| format!("device {id}"));
let rate: f64 = get(
id,
kAudioDevicePropertyNominalSampleRate,
kAudioObjectPropertyScopeGlobal,
)
.context("device sample rate")?;
if !(8000.0..=384_000.0).contains(&rate) {
bail!("device {name} reports an absurd sample rate {rate}");
}
let range: AudioValueRange = get(
id,
kAudioDevicePropertyBufferFrameSizeRange,
kAudioObjectPropertyScopeGlobal,
)
.context("device buffer range")?;
let latency_frames = get::<u32>(
id,
kAudioDevicePropertyLatency,
kAudioDevicePropertyScopeOutput,
)
.unwrap_or(0);
let safety_frames = get::<u32>(
id,
kAudioDevicePropertySafetyOffset,
kAudioDevicePropertyScopeOutput,
)
.unwrap_or(0);
Ok(Device {
id,
name,
sample_rate: rate.round() as u32,
buffer_range: (
range.mMinimum.max(1.0) as u32,
range.mMaximum.max(1.0) as u32,
),
latency_frames,
safety_frames,
})
}
// ---------------------------------------------------------------------------------------------
// Stream
/// What the IOProc sees each cycle: the interleaved stereo buffer to fill and the host time
/// at which its first frame reaches the DAC (the HAL's own deadline for this cycle).
pub(super) struct Cycle<'a> {
pub buffer: &'a mut [f32],
pub frames: usize,
/// `AudioTimeStamp::mHostTime`, in nanoseconds on the [`now_ns`] clock. Zero if the HAL
/// did not stamp a host time (never seen on a real device; defended anyway).
pub output_time_ns: u64,
}
/// An open, running output stream on one device. Dropping it stops the unit and frees the
/// callback synchronously — nothing runs on the IO thread after the drop returns.
pub(super) struct Stream {
unit: AudioUnit,
/// The IO buffer actually granted, frames.
pub buffer_frames: u32,
}
impl Stream {
/// Open `device` at its nominal rate with a per-client IO buffer of `buffer_frames` (clamped
/// to the device's range) and start it. `on_cycle` runs on the HAL's realtime IO thread —
/// it must never block, allocate, or log.
pub(super) fn open<F>(device: Device, buffer_frames: u32, mut on_cycle: F) -> Result<Self>
where
F: FnMut(Cycle<'_>) + Send + 'static,
{
let buffer_frames = buffer_frames.clamp(device.buffer_range.0, device.buffer_range.1);
// The IO buffer is a per-client property on the modern HAL — our request sizes OUR IO
// cycle and nobody else's (verified 2026-09-02: a second process read 512 while this
// client ran at 2048). Set on the device object before the unit starts.
set(
device.id,
kAudioDevicePropertyBufferFrameSize,
kAudioObjectPropertyScopeGlobal,
&buffer_frames,
)
.context("device buffer size")?;
let mut unit = AudioUnit::new(IOType::HalOutput).context("HAL output unit")?;
unit.set_property(
OUTPUT_UNIT_CURRENT_DEVICE,
Scope::Global,
Element::Output,
Some(&device.id),
)
.context("pinning the unit to the device")?;
// Our format on the unit's input scope of the output element: what we hand it.
let format = StreamFormat {
sample_rate: f64::from(device.sample_rate),
sample_format: SampleFormat::F32,
flags: LinearPcmFlags::IS_FLOAT | LinearPcmFlags::IS_PACKED,
channels: CHANNELS,
};
unit.set_stream_format(format, Scope::Input, Element::Output)
.context("stream format")?;
// Raise the slice ceiling to the buffer we asked for, or the unit refuses big cycles.
unit.set_property(
UNIT_MAXIMUM_FRAMES_PER_SLICE,
Scope::Global,
Element::Output,
Some(&buffer_frames.max(1156)),
)
.context("maximum frames per slice")?;
type Args = render_callback::Args<data::Interleaved<f32>>;
unit.set_render_callback(move |args: Args| {
let Args {
data,
time_stamp,
num_frames,
..
} = args;
let output_time_ns = if time_stamp.mHostTime == 0 {
0
} else {
host_ticks_to_ns(time_stamp.mHostTime)
};
on_cycle(Cycle {
buffer: data.buffer,
frames: num_frames,
output_time_ns,
});
Ok(())
})
.context("render callback")?;
unit.start().context("starting the unit")?;
// The HAL may quietly hand back a different size than asked (Chromium documents the
// silent clamp); the report prints the one that is actually running.
let buffer_frames = get::<u32>(
device.id,
kAudioDevicePropertyBufferFrameSize,
kAudioObjectPropertyScopeGlobal,
)
.unwrap_or(buffer_frames);
Ok(Self {
unit,
buffer_frames,
})
}
}
impl Drop for Stream {
fn drop(&mut self) {
// Explicit, so the order is ours: stop the IO thread first, then the unit's own Drop
// uninitialises and frees the callback (and with it whatever the closure owned).
let _ = self.unit.stop();
}
}
// ---------------------------------------------------------------------------------------------
// Listeners
/// The flags CoreAudio's notification thread raises; the backend polls them on the main
/// thread. Atomics only — a listener runs on CoreAudio's own thread and may not touch us.
#[derive(Default)]
pub(super) struct Notices {
/// The system default output device changed (headphones plugged, AirPods connected…).
pub default_changed: AtomicBool,
/// The device we are on says it is no longer alive (unplugged).
pub device_died: AtomicBool,
/// The device's nominal sample rate changed under us (Audio MIDI Setup).
pub rate_changed: AtomicBool,
/// `kAudioDeviceProcessorOverload` count — the HAL saying our IO cycle ran past its
/// deadline. The one crackle signature that is definitionally audible.
pub overloads: AtomicU64,
/// Host time (ns) of the most recent overload, so the report can place it.
pub last_overload_ns: AtomicU64,
}
unsafe extern "C-unwind" fn on_notice(
_object: AudioObjectID,
count: u32,
addresses: NonNull<AudioObjectPropertyAddress>,
client: *mut c_void,
) -> i32 {
// SAFETY: `client` is the `Arc<Notices>` pointer registered with the listener; the backend
// removes every listener before it drops that Arc.
let notices = unsafe { &*(client as *const Notices) };
for i in 0..count as usize {
// SAFETY: CoreAudio hands `count` valid addresses.
let address = unsafe { *addresses.as_ptr().add(i) };
match address.mSelector {
s if s == kAudioHardwarePropertyDefaultOutputDevice => {
notices.default_changed.store(true, Ordering::Release);
}
s if s == kAudioDevicePropertyDeviceIsAlive => {
notices.device_died.store(true, Ordering::Release);
}
s if s == kAudioDevicePropertyNominalSampleRate => {
notices.rate_changed.store(true, Ordering::Release);
}
s if s == kAudioDeviceProcessorOverload => {
notices.last_overload_ns.store(now_ns(), Ordering::Relaxed);
notices.overloads.fetch_add(1, Ordering::Relaxed);
}
_ => {}
}
}
0
}
/// The listeners armed on one device (plus the one system-wide default-device listener),
/// removed on drop. Rebuilding the stream on a new device drops and re-arms.
pub(super) struct Listeners {
notices: Arc<Notices>,
armed: Vec<(AudioObjectID, AudioObjectPropertyAddress)>,
}
impl Listeners {
pub(super) fn arm(device: AudioObjectID, notices: Arc<Notices>) -> Self {
let client = Arc::as_ptr(&notices) as *mut c_void;
let wanted = [
(
kAudioObjectSystemObject as AudioObjectID,
addr(
kAudioHardwarePropertyDefaultOutputDevice,
kAudioObjectPropertyScopeGlobal,
),
),
(
device,
addr(
kAudioDevicePropertyDeviceIsAlive,
kAudioObjectPropertyScopeGlobal,
),
),
(
device,
addr(
kAudioDevicePropertyNominalSampleRate,
kAudioObjectPropertyScopeGlobal,
),
),
(
device,
addr(
kAudioDeviceProcessorOverload,
kAudioObjectPropertyScopeGlobal,
),
),
];
let mut armed = Vec::with_capacity(wanted.len());
for (object, address) in wanted {
// SAFETY: `client` stays valid while `self` holds the Arc; removed in Drop.
let status = unsafe {
AudioObjectAddPropertyListener(
object,
NonNull::from(&address),
Some(on_notice),
client,
)
};
if status == 0 {
armed.push((object, address));
} else {
bevy::log::warn!(
"audio: listener {} on object {object} refused (OSStatus {status})",
fourcc(address.mSelector)
);
}
}
Self { notices, armed }
}
}
impl Drop for Listeners {
fn drop(&mut self) {
let client = Arc::as_ptr(&self.notices) as *mut c_void;
for (object, address) in self.armed.drain(..) {
// SAFETY: mirrors the registration above. A dead device may refuse; that is fine —
// its listeners die with it.
let _ = unsafe {
AudioObjectRemovePropertyListener(
object,
NonNull::from(&address),
Some(on_notice),
client,
)
};
}
}
}
// ---------------------------------------------------------------------------------------------
// Realtime scheduling for the render thread
/// `os_workgroup_t` is an ObjC object pointer; we never look inside it.
type OsWorkgroup = *mut c_void;
/// `os_workgroup_join_token_s`: a signature plus 36 opaque bytes on 64-bit. Sized generously —
/// the OS writes into it, we only carry it back to `leave`.
#[repr(C)]
struct JoinToken {
sig: u32,
opaque: [u8; 60],
}
extern "C" {
fn os_workgroup_join(wg: OsWorkgroup, token_out: *mut JoinToken) -> i32;
fn os_workgroup_leave(wg: OsWorkgroup, token: *mut JoinToken);
fn os_release(object: *mut c_void);
}
/// The device's IO-thread audio workgroup — the scheduler's notion of "the threads working
/// toward this device's deadline" (WWDC20 *Meet Audio Workgroups*). Retained; released on drop.
pub(super) struct Workgroup(OsWorkgroup);
// SAFETY: an os_workgroup is a thread-safe OS object; we only pass its pointer to `join` /
// `leave` on the joining thread and `os_release` once.
unsafe impl Send for Workgroup {}
impl Workgroup {
pub(super) fn of_device(device: AudioObjectID) -> Option<Self> {
let wg: OsWorkgroup = get(
device,
kAudioDevicePropertyIOThreadOSWorkgroup,
kAudioObjectPropertyScopeGlobal,
)
.ok()?;
(!wg.is_null()).then_some(Self(wg))
}
}
impl Drop for Workgroup {
fn drop(&mut self) {
// SAFETY: the property hands us a retained object we own.
unsafe { os_release(self.0) };
}
}
/// The render thread's membership in a workgroup — leaves on drop, on the same thread.
pub(super) struct Joined {
group: Workgroup,
token: Box<JoinToken>,
}
impl Joined {
/// Join the calling thread to `group`. Must be called from the thread that will render,
/// after [`set_realtime`] (a thread joins as what it is).
pub(super) fn join(group: Workgroup) -> Result<Self> {
let mut token = Box::new(JoinToken {
sig: 0,
opaque: [0; 60],
});
// SAFETY: the group pointer is a live retained object; the token is ours to hold.
let rc = unsafe { os_workgroup_join(group.0, &mut *token) };
if rc != 0 {
bail!("os_workgroup_join failed ({rc})");
}
Ok(Self { group, token })
}
}
impl Drop for Joined {
fn drop(&mut self) {
// SAFETY: `leave` with the token `join` filled, from the same thread (the render thread
// drops its own membership before it exits or re-joins).
unsafe { os_workgroup_leave(self.group.0, &mut *self.token) };
}
}
/// Give the calling thread a time-constraint (realtime) policy: it produces `period_ns` worth
/// of audio per wake and must be done well inside that. The scheduler then treats it like the
/// HAL's own IO thread — above every QoS band, on a performance core.
pub(super) fn set_realtime(period_ns: u64) -> Result<()> {
let period = ns_to_host_ticks(period_ns) as u32;
// A render chunk costs a fraction of a millisecond; the constraint is the deadline from
// period start by which that computation must be done. Half the period leaves the other
// half to the IO thread's own copy and to any neighbour in the workgroup.
let policy = libc::thread_time_constraint_policy {
period,
computation: (period / 10).max(1),
constraint: (period / 2).max(2),
preemptible: 1,
};
// SAFETY: the policy struct is the documented flavor's layout, with its documented count.
let rc = unsafe {
libc::thread_policy_set(
libc::pthread_mach_thread_np(libc::pthread_self()),
libc::THREAD_TIME_CONSTRAINT_POLICY as libc::thread_policy_flavor_t,
std::ptr::from_ref(&policy) as libc::thread_policy_t,
libc::THREAD_TIME_CONSTRAINT_POLICY_COUNT,
)
};
if rc != 0 {
return Err(anyhow!("thread_policy_set(TIME_CONSTRAINT) failed ({rc})"));
}
Ok(())
}
+736
View File
@@ -0,0 +1,736 @@
//! The output stream — benilla's own kira backend (decision 1857).
//!
//! kira renders the mix; this module owns everything between that render and the speaker:
//! the device, the IO callback, the thread the mix runs on, and every meter that says whether
//! the sound reached the hardware on time.
//!
//! ## The shape: mix ahead, copy on the deadline
//!
//! The HAL calls our IOProc on its realtime IO thread once per device cycle, with a hard
//! budget: the buffer must be filled before the DMA reaches it. The 2026-09-02 overload the OS
//! itself annotated (`HALS_OverloadMessage: … HAL client proc exceeding io cycle budget` /
//! `safety violation`) was that budget blown while the mix's own compute was ~1% of it — the
//! callback did not *run* long, it *waited* long, on a machine with its swap 94% full and a
//! load average of 26. A page-in, a stall, anything that parks the thread for tens of
//! milliseconds inside the callback is a skipped cycle, and a skipped cycle is a crackle.
//!
//! So the IOProc no longer renders. A render thread of ours runs kira's [`Renderer`] ahead of
//! the device into a lock-free ring, and the IOProc copies from the ring — a few hundred
//! floats, hot in cache, nothing else. A stall now has the ring's depth to hide in instead of
//! one cycle's budget: [`OutputSettings::mix_ahead_ms`] of it, the reference's own
//! `SoundBufferSize` (FMOD 3's mix-ahead buffer, registered at 50 or 100 ms by host). The
//! render thread is scheduled the way Apple documents for an audio worker — a time-constraint
//! (realtime) policy, joined to the device's IO workgroup (`coreaudio::set_realtime`,
//! `coreaudio::Joined`) — so under CPU contention it is served like the HAL's own thread.
//!
//! ## What the meters can now say
//!
//! Every number that names a crackle's layer is read here and reported by
//! [`OutputBackend::service`] on the main thread:
//!
//! - **lead** — at IOProc entry, the host time the buffer is due at the DAC minus now: the
//! HAL's own deadline for this cycle. A late wake shrinks it; negative means already late.
//! - **io** — the IOProc's wall time entry→exit. The copy costs microseconds; anything larger
//! is the thread being parked inside the callback (the mechanism above).
//! - **gap** — spacing of successive cycle timestamps; a doubled gap is a cycle the HAL skipped.
//! - **underruns** — cycles the ring could not fill: the render thread fell the whole ring
//! behind. The audible failure this design leaves, counted rather than guessed.
//! - **render** — the render thread's per-chunk wall time, kira's `cpu_usage` equivalent.
//! - **overloads** — `kAudioDeviceProcessorOverload`, stamped with the host time it fired.
//!
//! ## What the device layer does that the old stack could not
//!
//! Default-output changes, device loss and rate changes each raise a notice; `service`
//! rebuilds the stream on the new default. kira's own cpal backend could do none of that on
//! macOS (its device poll is compiled out there, and cpal 0.17 wired a no-op error callback
//! for the default device) — unplug the headphones and the old stream paused forever.
//!
//! The callback and the render loop are allocation-free by construction and *checked*: in
//! debug builds `assert_no_alloc` aborts the process on an allocation inside either.
#[cfg(target_os = "macos")]
mod coreaudio;
#[cfg(not(target_os = "macos"))]
#[path = "stub.rs"]
mod coreaudio;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use bevy::log::{debug, warn};
use kira::backend::{Backend, Renderer};
use rtrb::{Consumer, Producer, RingBuffer};
use coreaudio::{Cycle, Device, Joined, Listeners, Notices, Stream, Workgroup};
/// Frames the render thread produces per pass — its scheduling period. 256 at 48 kHz is
/// 5.3 ms: two of kira's 128-frame parameter blocks, small enough to keep the ring topped up
/// finely, large enough that the thread is not the busiest thing on the machine.
const RENDER_CHUNK_FRAMES: usize = 256;
/// How long an unopenable device waits before the next attempt.
const REOPEN_EVERY: Duration = Duration::from_secs(1);
/// How long the main thread waits for the IO closure to hand the ring consumer back after a
/// stream is dropped. The unit stop is synchronous; this is a defence, not an expectation.
const HANDBACK_WAIT: Duration = Duration::from_millis(250);
/// The device IO buffer we ask for. The IOProc is a copy now, so this bounds no compute; it
/// sets how often the HAL wakes us and how much of the mix-ahead each wake drains. 512 is
/// what shipping engines run on macOS (FMOD's and Godot's block, the device's own default
/// here) and the opposite of the 2048 the old design reached for: this machine's overload
/// telemetry (`io_page_faults_duration: 0`, `IOWorkLoop: skipping cycle due to overload`)
/// says the IO thread was scheduled late, not run long — and a realtime thread that sleeps
/// 43 ms between 0.4 ms of work is exactly the shape Apple Silicon parks on an efficiency
/// core (developer forums 726096: workgroup threads land on E-cores from 512 frames up).
/// A thread woken 94×/s stays warm and stays placed; the mix-ahead absorbs what it can't.
pub(super) const DEVICE_BUFFER_FRAMES: u32 = 512;
/// How far ahead of the device the mix runs. The reference registers `SoundBufferSize` — FMOD 3's
/// mix-ahead, the same quantity — at `"50"` or `"100"` by host (`0x457520`, strings at
/// `0x835e10`/`0x835e0c`, byte-read 2026-09-02); the larger of its two is ours, because the
/// stall we measured a crackle from was a whole IO cycle long and the point of the depth is to
/// hide the next one.
pub(super) const MIX_AHEAD_MS: u32 = 100;
/// Whether an output stream is open right now — read by instruments that would stop the
/// process's threads (decision 1857: `/usr/bin/sample` suspends the task, realtime IO thread
/// included, and every HAL cycle it holds is a crackle; the stall watchdog stands down while
/// this is set). Set by the backend around the stream's life, never by anything else.
static DEVICE_OPEN: AtomicBool = AtomicBool::new(false);
/// True while an output stream is open on a device.
pub(crate) fn device_open() -> bool {
DEVICE_OPEN.load(Ordering::Acquire)
}
/// The device's current default rate, read without opening anything — for the pieces of the
/// mix that are sized before the backend exists (the tap's WAV header, the limiter's delay).
pub(super) fn probe_sample_rate() -> Option<u32> {
coreaudio::default_output().ok().map(|d| d.sample_rate)
}
/// The dials the mixer opens the device with.
#[derive(Clone, Copy, Debug)]
pub(super) struct OutputSettings {
/// The device IO buffer, frames (clamped to the device's range). The IOProc is a copy,
/// so this no longer bounds any compute — it sets how often the HAL wakes us and how much
/// of the mix-ahead is consumed per wake.
pub device_buffer_frames: u32,
/// How far ahead of the device the mix runs, milliseconds — the reference's
/// `SoundBufferSize`. The stall the output can absorb without an audible gap.
pub mix_ahead_ms: u32,
}
impl Default for OutputSettings {
fn default() -> Self {
Self {
device_buffer_frames: DEVICE_BUFFER_FRAMES,
mix_ahead_ms: MIX_AHEAD_MS,
}
}
}
/// One `service` window's readings, in the units the report prints.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Window {
/// IOProc calls in the window.
pub cycles: u64,
/// Smallest lead at IOProc entry (ms; negative = late). `None` when no cycle stamped one.
pub lead_min_ms: Option<f64>,
/// Longest IOProc wall time (ms).
pub io_wall_max_ms: f64,
/// Longest spacing between cycle output times (ms). Nominal is one device buffer.
pub gap_max_ms: f64,
/// Cycles the ring could not fill, and the audible silence that cost (ms).
pub underruns: u64,
pub underrun_ms: f64,
/// The ring's low-water mark at IOProc entry (ms of audio banked).
pub ring_min_ms: Option<f64>,
/// Longest render pass for one chunk (ms), and how many chunks ran.
pub render_wall_max_ms: f64,
pub render_chunks: u64,
/// HAL overloads in the window, and how long ago the last one fired (ms).
pub overloads: u64,
pub last_overload_ago_ms: Option<f64>,
/// Nominal figures for the report: one device buffer (ms) and one render chunk (ms).
pub cycle_ms: f64,
pub chunk_ms: f64,
}
impl Window {
/// Fold a later window into this one: sums add, extrema keep the extreme, nominals follow.
pub(super) fn merge(&mut self, later: Window) {
self.cycles += later.cycles;
self.lead_min_ms = match (self.lead_min_ms, later.lead_min_ms) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
self.io_wall_max_ms = self.io_wall_max_ms.max(later.io_wall_max_ms);
self.gap_max_ms = self.gap_max_ms.max(later.gap_max_ms);
self.underruns += later.underruns;
self.underrun_ms += later.underrun_ms;
self.ring_min_ms = match (self.ring_min_ms, later.ring_min_ms) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
self.render_wall_max_ms = self.render_wall_max_ms.max(later.render_wall_max_ms);
self.render_chunks += later.render_chunks;
self.overloads += later.overloads;
if later.last_overload_ago_ms.is_some() {
self.last_overload_ago_ms = later.last_overload_ago_ms;
}
self.cycle_ms = later.cycle_ms;
self.chunk_ms = later.chunk_ms;
}
}
/// Something `service` did or saw that the mixer should log.
#[derive(Debug)]
pub(super) enum Event {
Opened {
device: String,
sample_rate: u32,
buffer_frames: u32,
mix_ahead_ms: u32,
realtime_latency_frames: u32,
},
Lost(&'static str),
OpenFailed(String),
/// The stream is gone for good (the ring's consumer never came back — a bug, not a device).
Dead(String),
}
/// Meters written on the IO thread and the render thread, drained by `service`. Atomics only.
#[derive(Default)]
struct Meters {
cycles: AtomicU64,
lead_min_ns: AtomicI64,
io_wall_max_ns: AtomicU64,
gap_max_ns: AtomicU64,
underruns: AtomicU64,
underrun_frames: AtomicU64,
ring_min_frames: AtomicUsize,
render_wall_max_ns: AtomicU64,
render_chunks: AtomicU64,
}
impl Meters {
fn reset_extrema(&self) {
self.lead_min_ns.store(i64::MAX, Ordering::Relaxed);
self.ring_min_frames.store(usize::MAX, Ordering::Relaxed);
}
}
/// What the main thread tells the render thread. The render thread only ever `try_lock`s the
/// mutex, and only `service` writes it, so the audio path never blocks on it.
#[derive(Default)]
struct Control {
stop: AtomicBool,
/// A sample rate the renderer must switch to (0 = none pending).
pending_rate: AtomicU32,
/// A workgroup to (re)join after a device change.
regroup: AtomicBool,
new_group: Mutex<Option<Workgroup>>,
/// The render thread, for the IOProc's wake-up.
render_thread: OnceLock<std::thread::Thread>,
}
#[derive(Default)]
struct Shared {
meters: Meters,
control: Control,
notices: Arc<Notices>,
}
/// Hands `T` back through a one-slot ring when dropped — how the ring consumer inside the
/// IO closure returns to the main thread when a stream is torn down.
struct Returning<T> {
inner: Option<T>,
back: Producer<T>,
}
impl<T> Returning<T> {
fn new(value: T) -> (Self, Consumer<T>) {
let (back, receiver) = RingBuffer::new(1);
(
Self {
inner: Some(value),
back,
},
receiver,
)
}
fn get_mut(&mut self) -> &mut T {
self.inner.as_mut().expect("value present until drop")
}
}
impl<T> Drop for Returning<T> {
fn drop(&mut self) {
if let Some(value) = self.inner.take() {
let _ = self.back.push(value);
}
}
}
enum Stage {
/// `setup` ran; `start` has not.
Set(Device),
Running {
stream: Stream,
_listeners: Listeners,
handback: Consumer<Consumer<f32>>,
},
/// No stream; the ring consumer is ours; retrying.
Idle {
consumer: Consumer<f32>,
since: Instant,
},
Dead,
}
/// benilla's kira backend: the render thread, the ring, the device stream and its meters.
pub(super) struct OutputBackend {
settings: OutputSettings,
shared: Arc<Shared>,
stage: Stage,
sample_rate: u32,
buffer_frames: u32,
render: Option<std::thread::JoinHandle<()>>,
/// Events raised before the first `service` (the initial open), drained by it.
pending: Vec<Event>,
overloads_seen: u64,
/// Errors the report counts: stream lost / open failed, since launch.
stream_errors: u64,
}
impl Backend for OutputBackend {
type Settings = OutputSettings;
type Error = anyhow::Error;
fn setup(settings: Self::Settings, _internal_buffer_size: usize) -> Result<(Self, u32)> {
let device = coreaudio::default_output()?;
let shared = Arc::new(Shared::default());
shared.meters.reset_extrema();
let sample_rate = device.sample_rate;
Ok((
Self {
settings,
shared,
stage: Stage::Set(device),
sample_rate,
buffer_frames: settings.device_buffer_frames,
render: None,
pending: Vec::new(),
overloads_seen: 0,
stream_errors: 0,
},
sample_rate,
))
}
fn start(&mut self, renderer: Renderer) -> Result<()> {
let Stage::Set(device) = std::mem::replace(&mut self.stage, Stage::Dead) else {
anyhow::bail!("output backend started twice");
};
let buffer_frames = self
.settings
.device_buffer_frames
.clamp(device.buffer_range.0, device.buffer_range.1);
// Guaranteed slack is the ring minus one device buffer (the IOProc drains a buffer's
// worth per wake before the render thread tops up), so the ring is sized for both.
let ahead_frames = ms_to_frames(self.settings.mix_ahead_ms, self.sample_rate);
let ring_frames = ahead_frames + buffer_frames as usize;
let (producer, consumer) = RingBuffer::<f32>::new(ring_frames * 2);
let group = Workgroup::of_device(device.id);
let shared = Arc::clone(&self.shared);
let period_ns = frames_to_ns(RENDER_CHUNK_FRAMES, self.sample_rate);
let handle = std::thread::Builder::new()
.name("audio-render".into())
.spawn(move || render_loop(renderer, producer, shared, group, period_ns))
.context("spawning the render thread")?;
let _ = self
.shared
.control
.render_thread
.set(handle.thread().clone());
self.render = Some(handle);
self.stage = Stage::Idle {
consumer,
since: Instant::now() - REOPEN_EVERY,
};
if let Some(event) = self.open(device) {
self.pending.push(event);
}
Ok(())
}
}
impl OutputBackend {
/// The rate the renderer runs at (follows the device across rebuilds).
pub(super) fn sample_rate(&self) -> u32 {
self.sample_rate
}
/// Stream-side failures since launch (a lost device, a refused open).
pub(super) fn stream_errors(&self) -> u64 {
self.stream_errors
}
/// Main-thread service: react to device notices (rebuild the stream on the new default),
/// retry an unopened device, and drain the window's meters. Cheap when nothing happened.
pub(super) fn service(&mut self) -> (Window, Vec<Event>) {
let mut events = std::mem::take(&mut self.pending);
let notices = &self.shared.notices;
let lost = if notices.device_died.swap(false, Ordering::AcqRel) {
Some("the output device went away")
} else if notices.default_changed.swap(false, Ordering::AcqRel) {
Some("the system default output changed")
} else if notices.rate_changed.swap(false, Ordering::AcqRel) {
Some("the device's sample rate changed")
} else {
None
};
if let Some(why) = lost {
if matches!(self.stage, Stage::Running { .. }) {
self.stream_errors += 1;
events.push(Event::Lost(why));
if let Err(e) = self.close() {
self.stage = Stage::Dead;
events.push(Event::Dead(format!("{e:#}")));
}
}
}
if let Stage::Idle { since, .. } = &self.stage {
if since.elapsed() >= REOPEN_EVERY {
match coreaudio::default_output() {
Ok(device) => {
if let Some(event) = self.open(device) {
events.push(event);
}
}
Err(e) => {
if let Stage::Idle { since, .. } = &mut self.stage {
*since = Instant::now();
}
self.stream_errors += 1;
events.push(Event::OpenFailed(format!("{e:#}")));
}
}
}
}
(self.take_window(), events)
}
/// Open a stream on `device` with the ring consumer held in `Stage::Idle`. Returns the
/// event to log; leaves the stage `Idle` (with the retry clock reset) on failure.
fn open(&mut self, device: Device) -> Option<Event> {
let Stage::Idle { consumer, .. } = std::mem::replace(&mut self.stage, Stage::Dead) else {
self.stage = Stage::Dead;
return Some(Event::Dead("open called without the ring consumer".into()));
};
if device.sample_rate != self.sample_rate {
self.sample_rate = device.sample_rate;
self.shared
.control
.pending_rate
.store(device.sample_rate, Ordering::Release);
if let Some(thread) = self.shared.control.render_thread.get() {
thread.unpark();
}
}
let (mut returning, handback) = Returning::new(consumer);
let shared = Arc::clone(&self.shared);
let mut last_output_ns = 0u64;
// The closure owns the ring consumer (inside `returning`, which hands it back when the
// closure is dropped with the stream) and a clone of the shared meters.
let on_cycle = move |cycle: Cycle<'_>| {
io_cycle(cycle, returning.get_mut(), &shared, &mut last_output_ns);
};
match Stream::open(device.clone(), self.settings.device_buffer_frames, on_cycle) {
Ok(stream) => {
self.buffer_frames = stream.buffer_frames;
// The render thread joins the new device's workgroup on its next pass.
if let Some(group) = Workgroup::of_device(device.id) {
if let Ok(mut slot) = self.shared.control.new_group.lock() {
*slot = Some(group);
self.shared.control.regroup.store(true, Ordering::Release);
}
}
let listeners = Listeners::arm(device.id, Arc::clone(&self.shared.notices));
self.shared.meters.reset_extrema();
DEVICE_OPEN.store(true, Ordering::Release);
let event = Event::Opened {
device: device.name.clone(),
sample_rate: device.sample_rate,
buffer_frames: stream.buffer_frames,
mix_ahead_ms: self.settings.mix_ahead_ms,
realtime_latency_frames: device.latency_frames + device.safety_frames,
};
self.stage = Stage::Running {
stream,
_listeners: listeners,
handback,
};
Some(event)
}
Err(e) => {
self.stream_errors += 1;
match take_back(handback) {
Some(consumer) => {
self.stage = Stage::Idle {
consumer,
since: Instant::now(),
};
Some(Event::OpenFailed(format!("{} — {e:#}", device.name)))
}
None => {
self.stage = Stage::Dead;
Some(Event::Dead(format!(
"ring consumer lost while opening {} — {e:#}",
device.name
)))
}
}
}
}
}
/// Tear the running stream down and take the ring consumer back.
fn close(&mut self) -> Result<()> {
let Stage::Running {
stream,
_listeners,
handback,
} = std::mem::replace(&mut self.stage, Stage::Dead)
else {
anyhow::bail!("close without a running stream");
};
DEVICE_OPEN.store(false, Ordering::Release);
drop(_listeners);
drop(stream);
let consumer = take_back(handback).context("the IO closure never returned the ring")?;
self.stage = Stage::Idle {
consumer,
since: Instant::now() - REOPEN_EVERY,
};
Ok(())
}
fn take_window(&mut self) -> Window {
let m = &self.shared.meters;
let rate = f64::from(self.sample_rate.max(1));
let frames_ms = |frames: f64| frames / rate * 1000.0;
let ns_ms = |ns: u64| ns as f64 / 1e6;
let lead_min = m.lead_min_ns.swap(i64::MAX, Ordering::Relaxed);
let ring_min = m.ring_min_frames.swap(usize::MAX, Ordering::Relaxed);
let overloads_total = self.shared.notices.overloads.load(Ordering::Relaxed);
let overloads = overloads_total - self.overloads_seen;
self.overloads_seen = overloads_total;
let last_overload_ago_ms = (overloads > 0).then(|| {
let at = self.shared.notices.last_overload_ns.load(Ordering::Relaxed);
ns_ms(coreaudio::now_ns().saturating_sub(at))
});
Window {
cycles: m.cycles.swap(0, Ordering::Relaxed),
lead_min_ms: (lead_min != i64::MAX).then(|| lead_min as f64 / 1e6),
io_wall_max_ms: ns_ms(m.io_wall_max_ns.swap(0, Ordering::Relaxed)),
gap_max_ms: ns_ms(m.gap_max_ns.swap(0, Ordering::Relaxed)),
underruns: m.underruns.swap(0, Ordering::Relaxed),
underrun_ms: frames_ms(m.underrun_frames.swap(0, Ordering::Relaxed) as f64),
ring_min_ms: (ring_min != usize::MAX).then(|| frames_ms(ring_min as f64)),
render_wall_max_ms: ns_ms(m.render_wall_max_ns.swap(0, Ordering::Relaxed)),
render_chunks: m.render_chunks.swap(0, Ordering::Relaxed),
overloads,
last_overload_ago_ms,
cycle_ms: frames_ms(f64::from(self.buffer_frames)),
chunk_ms: frames_ms(RENDER_CHUNK_FRAMES as f64),
}
}
}
impl Drop for OutputBackend {
fn drop(&mut self) {
// Stream first (no IOProc after this), then the render thread.
DEVICE_OPEN.store(false, Ordering::Release);
self.stage = Stage::Dead;
self.shared.control.stop.store(true, Ordering::Release);
if let Some(thread) = self.shared.control.render_thread.get() {
thread.unpark();
}
if let Some(handle) = self.render.take() {
let _ = handle.join();
}
}
}
/// Wait (briefly) for the ring consumer to come back out of a dropped IO closure.
fn take_back(mut handback: Consumer<Consumer<f32>>) -> Option<Consumer<f32>> {
let deadline = Instant::now() + HANDBACK_WAIT;
loop {
if let Ok(consumer) = handback.pop() {
return Some(consumer);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(1));
}
}
fn ms_to_frames(ms: u32, sample_rate: u32) -> usize {
(u64::from(ms) * u64::from(sample_rate) / 1000) as usize
}
fn frames_to_ns(frames: usize, sample_rate: u32) -> u64 {
(frames as u64 * 1_000_000_000) / u64::from(sample_rate.max(1))
}
/// Run `f` with allocation forbidden (debug builds abort on a violation; release runs it
/// bare). The tripwire that keeps the realtime paths honest across every future change.
#[inline]
fn no_alloc<R>(f: impl FnOnce() -> R) -> R {
#[cfg(debug_assertions)]
{
assert_no_alloc::assert_no_alloc(f)
}
#[cfg(not(debug_assertions))]
{
f()
}
}
/// The IOProc: copy one device buffer out of the ring, stamp the meters. Realtime thread —
/// no allocation, no lock, no log.
fn io_cycle(
cycle: Cycle<'_>,
consumer: &mut Consumer<f32>,
shared: &Shared,
last_output_ns: &mut u64,
) {
no_alloc(|| {
let entry = coreaudio::now_ns();
let meters = &shared.meters;
if cycle.output_time_ns != 0 {
let lead = cycle.output_time_ns as i64 - entry as i64;
meters.lead_min_ns.fetch_min(lead, Ordering::Relaxed);
if *last_output_ns != 0 {
let gap = cycle.output_time_ns.saturating_sub(*last_output_ns);
meters.gap_max_ns.fetch_max(gap, Ordering::Relaxed);
}
*last_output_ns = cycle.output_time_ns;
}
let out = cycle.buffer;
let need = out.len();
let banked = consumer.slots() / 2 * 2;
meters
.ring_min_frames
.fetch_min(banked / 2, Ordering::Relaxed);
let take = banked.min(need);
if take > 0 {
if let Ok(chunk) = consumer.read_chunk(take) {
let (a, b) = chunk.as_slices();
out[..a.len()].copy_from_slice(a);
out[a.len()..a.len() + b.len()].copy_from_slice(b);
chunk.commit_all();
}
}
if take < need {
out[take..].fill(0.0);
meters.underruns.fetch_add(1, Ordering::Relaxed);
meters
.underrun_frames
.fetch_add(((need - take) / 2) as u64, Ordering::Relaxed);
}
if let Some(thread) = shared.control.render_thread.get() {
thread.unpark();
}
meters.cycles.fetch_add(1, Ordering::Relaxed);
meters
.io_wall_max_ns
.fetch_max(coreaudio::now_ns().saturating_sub(entry), Ordering::Relaxed);
let _ = cycle.frames;
});
}
/// The render thread: keep the ring full of kira's output, one chunk at a time, woken by the
/// IOProc after every cycle (and by a timeout, so a missed wake cannot starve it).
fn render_loop(
mut renderer: Renderer,
mut producer: Producer<f32>,
shared: Arc<Shared>,
group: Option<Workgroup>,
period_ns: u64,
) {
match coreaudio::set_realtime(period_ns) {
Ok(()) => debug!(
"audio: render thread scheduled realtime, period {} µs",
period_ns / 1000
),
Err(e) => {
warn!("audio: render thread could not go realtime ({e}); using user-interactive QoS");
benilla_world::thread_qos::promote_current_thread(
benilla_world::thread_qos::QosClass::UserInteractive,
);
}
}
let mut joined = group.and_then(|g| match Joined::join(g) {
Ok(j) => {
debug!("audio: render thread joined the device's IO workgroup");
Some(j)
}
Err(e) => {
warn!("audio: render thread could not join the device workgroup ({e})");
None
}
});
let mut chunk = vec![0f32; RENDER_CHUNK_FRAMES * coreaudio::CHANNELS as usize];
let period = Duration::from_nanos(period_ns);
let control = &shared.control;
let meters = &shared.meters;
loop {
if control.stop.load(Ordering::Acquire) {
break;
}
let rate = control.pending_rate.swap(0, Ordering::AcqRel);
if rate != 0 {
// Off the no-alloc path on purpose: effects may resize their state here.
renderer.on_change_sample_rate(rate);
}
if control.regroup.swap(false, Ordering::AcqRel) {
if let Ok(mut slot) = control.new_group.try_lock() {
if let Some(group) = slot.take() {
drop(joined.take());
joined = Joined::join(group).ok();
}
} else {
control.regroup.store(true, Ordering::Release);
}
}
while producer.slots() >= chunk.len() {
let start = coreaudio::now_ns();
no_alloc(|| {
renderer.on_start_processing();
renderer.process(&mut chunk, coreaudio::CHANNELS as u16);
if let Ok(mut slot) = producer.write_chunk(chunk.len()) {
let (a, b) = slot.as_mut_slices();
a.copy_from_slice(&chunk[..a.len()]);
b.copy_from_slice(&chunk[a.len()..a.len() + b.len()]);
slot.commit_all();
}
});
meters.render_chunks.fetch_add(1, Ordering::Relaxed);
meters
.render_wall_max_ns
.fetch_max(coreaudio::now_ns().saturating_sub(start), Ordering::Relaxed);
}
std::thread::park_timeout(period);
}
drop(joined);
}
@@ -0,0 +1,89 @@
//! The non-macOS face of [`super`]: the same surface as `coreaudio.rs`, opening nothing.
//!
//! benilla's device layer is CoreAudio (decision 1857). On any other target the backend
//! reports "no output device" and the client runs silent — the same posture as a headless
//! machine — rather than carrying a second, untestable device layer. Porting means writing
//! this file's surface over the platform's audio API; nothing above it changes.
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{bail, Result};
pub(super) const CHANNELS: u32 = 2;
pub(super) fn now_ns() -> u64 {
static EPOCH: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_nanos() as u64
}
#[derive(Clone, Debug)]
pub(super) struct Device {
pub id: u32,
pub name: String,
pub sample_rate: u32,
pub buffer_range: (u32, u32),
pub latency_frames: u32,
pub safety_frames: u32,
}
pub(super) fn default_output() -> Result<Device> {
bail!("audio output is CoreAudio-only in this build (decision 1857)")
}
pub(super) struct Cycle<'a> {
pub buffer: &'a mut [f32],
pub frames: usize,
pub output_time_ns: u64,
}
pub(super) struct Stream {
pub buffer_frames: u32,
}
impl Stream {
pub(super) fn open<F>(device: Device, _buffer_frames: u32, _on_cycle: F) -> Result<Self>
where
F: FnMut(Cycle<'_>) + Send + 'static,
{
bail!("no audio device layer for this platform ({})", device.name)
}
}
#[derive(Default)]
pub(super) struct Notices {
pub default_changed: AtomicBool,
pub device_died: AtomicBool,
pub rate_changed: AtomicBool,
pub overloads: AtomicU64,
pub last_overload_ns: AtomicU64,
}
pub(super) struct Listeners;
impl Listeners {
pub(super) fn arm(_device: u32, _notices: Arc<Notices>) -> Self {
Self
}
}
pub(super) struct Workgroup;
impl Workgroup {
pub(super) fn of_device(_device: u32) -> Option<Self> {
None
}
}
pub(super) struct Joined;
impl Joined {
pub(super) fn join(_group: Workgroup) -> Result<Self> {
bail!("no audio workgroups on this platform")
}
}
pub(super) fn set_realtime(_period_ns: u64) -> Result<()> {
bail!("no realtime thread policy on this platform")
}
+327 -165
View File
@@ -135,6 +135,25 @@ pub(super) fn select_on_click(
}
}
/// The service arms that are **not** a bare packet, as one [`SystemParam`] (the 16-param ceiling).
///
/// Three of the reference's fourteen arms need something other than "send this opcode": bit 1
/// reads the target's cached questgiver status as its second conjunct, and bits 5 and 7 raise a
/// client-side CONFIRM dialog and send nothing at all (wow-re
/// `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct ServiceArms<'w> {
/// `[unit+0xcb8]`'s mirror — the last `SMSG_QUESTGIVER_STATUS` per guid, which the bit-1 arm's
/// predicate `0x5df490` reads off the TARGET.
pub(crate) quest: Res<'w, crate::ui_quest::QuestGiver>,
/// The innkeeper's bind question (bit 7): `0x5dfdc0` caches the guid and fires `CONFIRM_BINDER`
/// — `CMSG_BINDER_ACTIVATE` belongs to the dialog's Accept, not to the click.
pub(crate) binder: ResMut<'w, crate::ui_binder::BinderState>,
/// The spirit healer's XP-loss question (bit 5): `0x5df730` does the same, firing
/// `CONFIRM_XP_LOSS`.
pub(crate) death: ResMut<'w, crate::death::DeathNet>,
}
/// On a clean right-*click* (vanilla's context action — [`WorldRightClick`], never a turn-drag):
/// select the hovered unit, then act by the same classification the cursor used (wow-re
/// cursor-system.md §6). Three branches (decision 0081):
@@ -180,6 +199,8 @@ pub(super) fn act_on_right_click(
// line (the opener cast's local totem refusal, decision 0552) + the loot-target latch
// the loot branch arms (decision 0515), and the mailbox session the mailbox branch opens
// (decision 0544).
// The three non-packet service arms (decision 1861), bundled — see [`ServiceArms`].
mut service: ServiceArms,
ui_feedback: (
ResMut<crate::ui_action::UiErrorKeys>,
ResMut<crate::ui_action::CastErrors>,
@@ -730,24 +751,52 @@ pub(super) fn act_on_right_click(
debug!("right-click unit {guid:#x}: dead fork took no leg — nothing sent");
}
UnitBranch::Service if !cursor.unable => {
// An in-range friendly service NPC (the cursor already gated friendly + service + range):
// route the interact by the classified kind (decision 0081). The Buy kind is shared by
// banker and auctioneer (both classify to Buy(3), low-bit-first — a gossip-flagged banker
// already classified to Speak and routes through the gossip menu), so the dispatch reads
// the NPC's own flags to split them (decision 0604).
// An in-range friendly service NPC (the cursor already gated friendly + service +
// range): run **the reference's own ladder over `UNIT_NPC_FLAGS`** ([`service_arm`]).
//
// Not the cursor's kind, which is what this dispatched on until decision 1861. The
// reference runs two structurally identical ladders — one for the cursor
// (`0x482336`), one for the send (`0x5f0289`) — and the cursor MODE is a lossy
// projection of the winning bit: eight arms collapse to Speak(6) and two to Buy(3),
// so a kind-keyed dispatch cannot tell a banker from an auctioneer, nor a trainer
// from an innkeeper from a spirit healer. It sent `CMSG_GOSSIP_HELLO` for all eight.
let npc_flags = stores
.get(entity)
.map(|(s, _)| s.0.unit_npc_flags())
.unwrap_or(0);
if let Some(cmd) = interact_command(cursor.kind, guid, npc_flags) {
debug!("right-click interact: {guid:#x} ({:?})", cursor.kind);
let _ = seam.net.0.send(cmd);
// Talk at the NPC. The gesture's own anim carries WeaponFlags `0x10`, so the
// per-animation sheath reconcile stows a drawn weapon — a committed change that
// persists after the talk (decisions 0080/0081; no sheath wiring here).
if let Some((_, my_guid, _)) = me {
gestures.push(my_guid.0, crate::creature_anim::Gesture::Talk);
let Some(arm) = service_arm(npc_flags, service.quest.status(guid)) else {
// `0x5f05ca` — a unit that reaches the ladder and matches no consulted bit
// (repair-only, or a questgiver with nothing on offer) does nothing at all, and
// takes no gesture with it.
debug!("right-click interact: {guid:#x} matches no service bit — nothing sent");
return;
};
match service_action(arm, guid, self_store.is_some_and(|s| s.0.player_is_ghost())) {
ServiceAction::Send(cmd) => {
debug!("right-click interact: {guid:#x} ({arm:?})");
let _ = seam.net.0.send(cmd);
}
ServiceAction::AskBinder => {
debug!(
"right-click interact: {guid:#x} (innkeeper — CONFIRM_BINDER, no packet)"
);
service.binder.ask(guid);
}
ServiceAction::AskSpiritHealer => {
debug!("right-click interact: {guid:#x} (spirit healer — CONFIRM_XP_LOSS, no packet)");
service.death.ask_spirit_healer(guid);
}
ServiceAction::Silent(why) => {
debug!("right-click interact: {guid:#x} ({arm:?}) — silent: {why}");
}
}
// Talk at the NPC — on **every taken arm**, including the ones that send nothing:
// the reference calls the gesture after the arm's handler RETURNS, not after a send
// (each arm of `0x5f0130` ends `call 0x60bb30(0)` then `ret 8`). The gesture's own
// anim carries WeaponFlags `0x10`, so the per-animation sheath reconcile stows a drawn
// weapon — a committed change that persists after the talk (decisions 0080/0081).
if let Some((_, my_guid, _)) = me {
gestures.push(my_guid.0, crate::creature_anim::Gesture::Talk);
}
}
// The classifier's own range gray on a LIVE service NPC — outside the 5.5556 yd service
@@ -1072,78 +1121,154 @@ fn unit_branch(attack: bool, dead_fork: bool, leg: DeadUnitLeg) -> UnitBranch {
}
}
/// The interact packet a right-click on a friendly service NPC sends, by its classified
/// [`cursor_mode::CursorKind`] (decision 0081). A **vendor-only** NPC (Pickup) opens the stock list
/// directly; a **flight master** (Taxi) opens the taxi map directly (byte-verified — decision 0496
/// `CMSG_TAXIQUERYAVAILABLENODES`; the gossip taxi option still reaches the same menu
/// server-side, so a gossip-routed flight master isn't broken by this); every other service kind
/// opens the universal gossip menu — `CMSG_GOSSIP_HELLO` works on any interactable creature
/// (verified: the server passes `UNIT_NPC_FLAG_NONE`), and the gossip window shows whatever menu
/// comes back (the banker/trainer/innkeeper *windows* are their own arcs, but the generic hello is
/// faithful and harmless). Non-service kinds (Attack is handled above; Point/Skin aren't
/// interacts) send nothing. A lootable corpse also shows Pickup (the loot base mode) but never
/// reaches here — the loot branch routes it by classification first.
fn interact_command(
kind: cursor_mode::CursorKind,
guid: u64,
npc_flags: u32,
) -> Option<ClientCommand> {
use cursor_mode::CursorKind;
match kind {
CursorKind::Pickup => Some(ClientCommand::ListInventory { guid }),
CursorKind::Taxi => Some(ClientCommand::TaxiQueryNodes { guid }),
// Buy(3) is banker OR auctioneer (the ladder's shared leg), so it forks twice. A pure
// banker (bit 8 — the lowest service bit, and the only way Buy classified) opens the bank
// directly (`CMSG_BANKER_ACTIVATE`, decision 0604).
CursorKind::Buy if npc_flags & cursor_mode::npc_flags::BANKER != 0 => {
Some(ClientCommand::BankerActivate { guid })
/// **The reference's own NPC-service ladder** — `0x5f0130`'s first-match-wins walk over
/// `UNIT_NPC_FLAGS`, low bit to high (wow-re
/// `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C, every arm byte-verified
/// from its `shr`/`test` to its `push <opcode>`). The winning bit, **not** a cursor kind.
///
/// The cursor classifier `0x482200` runs a second, structurally identical ladder over the same
/// field in the same order, which is why keying the send on the classified kind looked right for
/// so long. It is not: the projection is lossy. Speak(6) is bits 0, 1, 5, 6, 9, 10, 11 and 13 —
/// eight arms, two of which send nothing — and Buy(3) is bits 8 and 12. A kind-keyed dispatch
/// cannot express this ladder, and benilla's sent `CMSG_GOSSIP_HELLO` for all eight Speak arms
/// (decision 1861).
///
/// **First-match-wins is load-bearing**: a GOSSIP+VENDOR NPC sends `CMSG_GOSSIP_HELLO` only, and
/// a stable master with a menu keeps the menu — which is where 1677's hand-written
/// `STABLEMASTER && !GOSSIP` conjunct came from. The bit order gives it for free.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ServiceArm {
/// bit 0 — `0x5f02a4` → `0x5df4d0`.
Gossip,
/// bit 1, **and** the target's cached questgiver status ∉ {0, 1} (`0x5f02c0` → `0x5df490`).
Questgiver,
/// bit 2 — `0x5f0317` → `0x5df5d0`.
Vendor,
/// bit 3 — `0x5f034e` → `0x5ed020`.
FlightMaster,
/// bit 4 — `0x5f0385` → `0x5df680`.
Trainer,
/// bit 5 — `0x5f03bc` → `0x5df730`. **Ghost-gated, and sends nothing.**
SpiritHealer,
/// bit 6 — `0x5f03f3` → `0x5df950`. **Ghost-gated.**
SpiritGuide,
/// bit 7 — `0x5f042a` → `0x5dfdc0`. **Sends nothing.**
Innkeeper,
/// bit 8 — `0x5f0461` → `0x5dffe0`.
Banker,
/// bit 9 — `0x5f04e3` → `0x5e0060`.
Petitioner,
/// bit 10 — `0x5f051a` → `0x5e00e0`.
TabardDesigner,
/// bit 11 — `0x5f0551` → `0x5e01a0`.
Battlemaster,
/// bit 12 — `0x5f0588` → `0x5e0220`.
Auctioneer,
/// bit 13 — `0x5f05bc` → `0x5e02a0`.
StableMaster,
}
/// The ladder itself. `None` = no consulted bit set (`0x5f05ca`) — the click does nothing, and
/// takes no talk gesture with it. Bit 14 (REPAIR) has no arm in the binary and none here.
///
/// The reference re-reads the flags field for a redundant `bits 9 AND 10` block before the plain
/// bit-9 test; it routes to the same handler bit 9 alone reaches, so it is dead in effect and is
/// not transcribed (recording it would be a distinction with no outcome).
fn service_arm(npc_flags: u32, quest_status: Option<u32>) -> Option<ServiceArm> {
use cursor_mode::npc_flags as f;
let bit = |m: u32| npc_flags & m != 0;
Some(if bit(f::GOSSIP) {
ServiceArm::Gossip
} else if bit(f::QUESTGIVER) && cursor_mode::questgiver_has_quest(quest_status) {
// The SAME predicate the cursor ladder uses at `0x482362`, shared rather than copied so
// the two can never disagree about which questgiver is worth talking to.
ServiceArm::Questgiver
} else if bit(f::VENDOR) {
ServiceArm::Vendor
} else if bit(f::FLIGHTMASTER) {
ServiceArm::FlightMaster
} else if bit(f::TRAINER) {
ServiceArm::Trainer
} else if bit(f::SPIRITHEALER) {
ServiceArm::SpiritHealer
} else if bit(f::SPIRITGUIDE) {
ServiceArm::SpiritGuide
} else if bit(f::INNKEEPER) {
ServiceArm::Innkeeper
} else if bit(f::BANKER) {
ServiceArm::Banker
} else if bit(f::PETITIONER) {
ServiceArm::Petitioner
} else if bit(f::TABARDDESIGNER) {
ServiceArm::TabardDesigner
} else if bit(f::BATTLEMASTER) {
ServiceArm::Battlemaster
} else if bit(f::AUCTIONEER) {
ServiceArm::Auctioneer
} else if bit(f::STABLEMASTER) {
ServiceArm::StableMaster
} else {
return None;
})
}
/// What a taken [`ServiceArm`] does. Three arms are not a packet at all.
pub(crate) enum ServiceAction {
/// The arm's own opcode.
Send(ClientCommand),
/// Raise `CONFIRM_BINDER` locally and send nothing (`0x5dfdc0`).
AskBinder,
/// Raise `CONFIRM_XP_LOSS` locally and send nothing (`0x5df730`).
AskSpiritHealer,
/// Nothing goes out. The payload is why, for the debug line.
Silent(&'static str),
}
/// One taken arm → what benilla does about it.
///
/// **The two "spirit" arms are ghost-gated at entry** (`0x5df74a` / `0x5df962`, the byte-identical
/// `PLAYER_FLAGS` bit 4 test): a LIVING player who right-clicks a spirit healer or spirit guide
/// carrying no gossip bit gets nothing at all — no packet, no event, no error. That is not an
/// omission here, it is the reference's own answer.
///
/// **Two arms deviate deliberately, and here is the whole of it**: `TabardDesigner` should send
/// `MSG_TABARDVENDOR_ACTIVATE` and `Battlemaster` `CMSG_BATTLEMASTER_HELLO`, and benilla has
/// neither window to open with the reply. They keep the universal gossip greeting, which against
/// vmangos still puts a usable menu on screen; sending the faithful opcode into a reply we drop
/// would trade a working affordance for a wire detail nobody can see. Each retires the moment its
/// window exists — that is the condition, written down (decision 1861).
fn service_action(arm: ServiceArm, guid: u64, ghost: bool) -> ServiceAction {
match arm {
ServiceArm::Gossip => ServiceAction::Send(ClientCommand::GossipHello { guid }),
ServiceArm::Questgiver => ServiceAction::Send(ClientCommand::QuestgiverHello { npc: guid }),
ServiceArm::Vendor => ServiceAction::Send(ClientCommand::ListInventory { guid }),
ServiceArm::FlightMaster => ServiceAction::Send(ClientCommand::TaxiQueryNodes { guid }),
ServiceArm::Trainer => ServiceAction::Send(ClientCommand::TrainerList { trainer: guid }),
ServiceArm::SpiritHealer if ghost => ServiceAction::AskSpiritHealer,
ServiceArm::SpiritHealer => ServiceAction::Silent("spirit healer, and we are alive"),
// Ghost-gated like its neighbour, and then a stated gap: the ghost's arm sends opcode
// `0x2E2` (the area spirit-healer time query) from one call deeper, and benilla has no
// battleground resurrect timer for the reply to fill. For a living player this IS the
// reference's answer; for a ghost it is the gap.
ServiceArm::SpiritGuide if ghost => {
ServiceAction::Silent("spirit guide — the 0x2E2 timer query is unbuilt")
}
// An auctioneer (bit 12) greets its house (decision 1511). Note what this does NOT do:
// open anything. The greeting is a round trip, and the window opens when the *reply*
// lands — that is the real client's own law, and it is also what makes the house id
// (which only the reply carries) available before the sell pane needs a deposit rate.
// An auctioneer that also carries the gossip bit never reaches here: the service ladder
// is first-match-wins from the low bits, so bit 0 pre-empts bit 12 and the menu's own
// auctioneer option asks the server for the same greeting.
CursorKind::Buy if npc_flags & cursor_mode::npc_flags::AUCTIONEER != 0 => {
Some(ClientCommand::AuctionHello { auctioneer: guid })
ServiceArm::SpiritGuide => ServiceAction::Silent("spirit guide, and we are alive"),
ServiceArm::Innkeeper => ServiceAction::AskBinder,
ServiceArm::Banker => ServiceAction::Send(ClientCommand::BankerActivate { guid }),
ServiceArm::Petitioner => {
ServiceAction::Send(ClientCommand::PetitionShowList { npc: guid })
}
// A stable master asks for its own pet list — the client sends `MSG_LIST_STABLED_PETS`
// ITSELF on the bit-13 interact leg (`0x5f05a1` → `0x5f05bc` → `0x5e02a0`, whose sole
// caller that is), and the window opens when the reply lands. This corrects decision
// 1676's "server-initiated, off the gossip option": that is how it opens against vmangos
// (`GOSSIP_OPTION_STABLEPET` → `SendStablePet`), but it is not what the client does.
//
// Gated on the gossip bit being absent, which is the honest bound: the cursor ladder above
// is first-match-wins and reaches its STABLEMASTER leg only when no lower service bit is
// set, but the reference's *interaction* dispatcher is a different function whose own
// ordering the carve did not enumerate. A stable master carrying a gossip menu therefore
// keeps the menu — where its own stable option asks the server for the same list — and one
// without a menu takes this leg, which is the case the bare bit-13 test exists for.
CursorKind::Speak
if npc_flags & cursor_mode::npc_flags::STABLEMASTER != 0
&& npc_flags & cursor_mode::npc_flags::GOSSIP == 0 =>
{
Some(ClientCommand::ListStabledPets { npc: guid })
// The two documented deviations — see this function's doc.
ServiceArm::TabardDesigner | ServiceArm::Battlemaster => {
ServiceAction::Send(ClientCommand::GossipHello { guid })
}
CursorKind::Speak | CursorKind::Buy | CursorKind::Trainer | CursorKind::Interact => {
Some(ClientCommand::GossipHello { guid })
ServiceArm::Auctioneer => {
ServiceAction::Send(ClientCommand::AuctionHello { auctioneer: guid })
}
ServiceArm::StableMaster => {
ServiceAction::Send(ClientCommand::ListStabledPets { npc: guid })
}
// Repair and Cast are UI-overlay modes only; Inspect and the data-named GameObject
// cursors (Mail/Mine/GatherHerbs/PickLock) belong to the GO branch, which routes above
// and never reaches this NPC-service dispatch — a unit never classifies to any of
// these. LootAll is loot-only (the corpse route above), never a service.
CursorKind::Point
| CursorKind::Attack
| CursorKind::Skin
| CursorKind::LootAll
| CursorKind::Repair
| CursorKind::Inspect
| CursorKind::Mail
| CursorKind::Mine
| CursorKind::GatherHerbs
| CursorKind::PickLock
| CursorKind::Cast => None,
}
}
@@ -1500,12 +1625,10 @@ mod tests {
unit_branch(false, true, leg),
UnitBranch::Dead(DeadUnitLeg::Nothing)
);
// ...and what that used to fall into. The pouch over a corpse and the pouch over a vendor
// are one cursor mode, so the old last arm read the corpse as a shop.
assert!(matches!(
interact_command(cursor_mode::CursorKind::Pickup, 0x42, 0),
Some(ClientCommand::ListInventory { .. })
));
// (What that used to fall into: the pouch over a corpse and the pouch over a vendor are
// one cursor mode, so the old last arm read the corpse as a shop. The dispatch no longer
// consults a cursor kind at all — 1861 — but the fork's terminality is the guarantee that
// does not depend on that, so it is the one asserted here.)
// A LIVE unit still reaches the service dispatch — the fix must not shut the door on the
// branch that is supposed to send.
assert_eq!(
@@ -1514,89 +1637,128 @@ mod tests {
);
}
/// **The ladder, bit by bit** — decision 1861. Every arm of `0x5f0130`'s first-match-wins walk
/// over `UNIT_NPC_FLAGS`, in the order the binary tests them.
#[test]
fn interact_routes_vendor_direct_and_gossip_universal() {
use cursor_mode::CursorKind;
// A vendor-only NPC (Pickup) opens the stock list directly (decision 0081).
assert!(matches!(
interact_command(CursorKind::Pickup, 0x42, 0x4),
Some(ClientCommand::ListInventory { guid: 0x42 })
));
// A flight master (Taxi) opens the taxi map directly (byte-verified — decision 0496).
assert!(matches!(
interact_command(CursorKind::Taxi, 0x77, 0x8),
Some(ClientCommand::TaxiQueryNodes { guid: 0x77 })
));
// Gossip (Speak) and the out-of-scope service kinds route through the universal hello.
for (kind, flags) in [
(CursorKind::Speak, 0x1),
(CursorKind::Trainer, 0x10),
(CursorKind::Interact, 0x80),
fn the_service_ladder_walks_the_reference_bit_order() {
use cursor_mode::npc_flags as f;
let has = Some(benilla_protocol::messages::dialog_status::AVAILABLE);
for (flags, arm) in [
(f::GOSSIP, ServiceArm::Gossip),
(f::VENDOR, ServiceArm::Vendor),
(f::FLIGHTMASTER, ServiceArm::FlightMaster),
(f::TRAINER, ServiceArm::Trainer),
(f::SPIRITHEALER, ServiceArm::SpiritHealer),
(f::SPIRITGUIDE, ServiceArm::SpiritGuide),
(f::INNKEEPER, ServiceArm::Innkeeper),
(f::BANKER, ServiceArm::Banker),
(f::PETITIONER, ServiceArm::Petitioner),
(f::TABARDDESIGNER, ServiceArm::TabardDesigner),
(f::BATTLEMASTER, ServiceArm::Battlemaster),
(f::AUCTIONEER, ServiceArm::Auctioneer),
(f::STABLEMASTER, ServiceArm::StableMaster),
] {
assert_eq!(service_arm(flags, None), Some(arm), "flags {flags:#x}");
}
// Bit 1 is the one arm with a second conjunct: the target's cached questgiver status.
assert_eq!(
service_arm(f::QUESTGIVER, has),
Some(ServiceArm::Questgiver)
);
assert_eq!(service_arm(f::QUESTGIVER, None), None);
// No consulted bit — REPAIR (bit 14) has no arm in the binary, so a repair-only NPC does
// nothing at all. Neither does an empty field.
assert_eq!(service_arm(0x4000, None), None);
assert_eq!(service_arm(0, None), None);
}
/// **First-match-wins, which is the half a kind-keyed dispatch could not express.** The two
/// collisions that made the old proxy wrong are the two this pins.
#[test]
fn the_service_ladder_is_first_match_wins() {
use cursor_mode::npc_flags as f;
// A gossip-flagged anything keeps its menu — this is where 1677's hand-written
// `STABLEMASTER && !GOSSIP` conjunct came from, now free.
for other in [
f::VENDOR,
f::TRAINER,
f::INNKEEPER,
f::STABLEMASTER,
f::BANKER,
] {
assert_eq!(
service_arm(f::GOSSIP | other, None),
Some(ServiceArm::Gossip)
);
}
// Banker (bit 8) before auctioneer (bit 12) — both were Buy(3) to the cursor.
assert_eq!(
service_arm(f::BANKER | f::AUCTIONEER, None),
Some(ServiceArm::Banker)
);
// Trainer (bit 4) before innkeeper (bit 7) — both were Speak/Interact to the cursor.
assert_eq!(
service_arm(f::TRAINER | f::INNKEEPER, None),
Some(ServiceArm::Trainer)
);
}
/// **The three arms that are not a packet**, and the packets the other eleven actually send.
/// The eight arms that used to collapse into `CMSG_GOSSIP_HELLO` are the point of this test.
#[test]
fn the_service_arms_send_what_the_reference_sends() {
let sent = |arm, ghost| match service_action(arm, 0x42, ghost) {
ServiceAction::Send(cmd) => format!("{cmd:?}"),
ServiceAction::AskBinder => "ask-binder".to_string(),
ServiceAction::AskSpiritHealer => "ask-spirit-healer".to_string(),
ServiceAction::Silent(_) => "silent".to_string(),
};
assert!(matches!(
service_action(ServiceArm::Questgiver, 0x42, false),
ServiceAction::Send(ClientCommand::QuestgiverHello { npc: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Trainer, 0x42, false),
ServiceAction::Send(ClientCommand::TrainerList { trainer: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Petitioner, 0x42, false),
ServiceAction::Send(ClientCommand::PetitionShowList { npc: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Vendor, 0x42, false),
ServiceAction::Send(ClientCommand::ListInventory { guid: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::FlightMaster, 0x42, false),
ServiceAction::Send(ClientCommand::TaxiQueryNodes { guid: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Banker, 0x42, false),
ServiceAction::Send(ClientCommand::BankerActivate { guid: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::Auctioneer, 0x42, false),
ServiceAction::Send(ClientCommand::AuctionHello { auctioneer: 0x42 })
));
assert!(matches!(
service_action(ServiceArm::StableMaster, 0x42, false),
ServiceAction::Send(ClientCommand::ListStabledPets { npc: 0x42 })
));
// The innkeeper asks, mounted or not, alive or not — and sends nothing.
assert_eq!(sent(ServiceArm::Innkeeper, false), "ask-binder");
// The two ghost-gated arms: nothing at all for a living player, which is the reference's
// own answer and not an omission.
assert_eq!(sent(ServiceArm::SpiritHealer, false), "silent");
assert_eq!(sent(ServiceArm::SpiritGuide, false), "silent");
assert_eq!(sent(ServiceArm::SpiritHealer, true), "ask-spirit-healer");
// The two documented deviations keep the greeting until their windows exist.
for arm in [ServiceArm::TabardDesigner, ServiceArm::Battlemaster] {
assert!(matches!(
interact_command(kind, 0x99, flags),
Some(ClientCommand::GossipHello { guid: 0x99 })
service_action(arm, 0x42, false),
ServiceAction::Send(ClientCommand::GossipHello { guid: 0x42 })
));
}
// Non-service cursors aren't interacts (Attack is handled by the attack branch).
for kind in [CursorKind::Point, CursorKind::Attack, CursorKind::Skin] {
assert!(interact_command(kind, 0x1, 0).is_none());
}
}
/// The auctioneer split (decision 1511): a pure auctioneer greets its house rather than
/// falling to the gossip universal, and the greeting is all it does — the window opens when
/// the *reply* lands, which is the real client's own law.
#[test]
fn interact_routes_pure_auctioneer_to_the_hello() {
use cursor_mode::CursorKind;
assert!(matches!(
interact_command(CursorKind::Buy, 0x51, cursor_mode::npc_flags::AUCTIONEER),
Some(ClientCommand::AuctionHello { auctioneer: 0x51 })
));
}
/// The stable-master split (decision 1677): the client asks for the pet list **itself** on the
/// bit-13 interact leg — it is not purely server-initiated, which is what 1676 had. A stable
/// master carrying a gossip menu keeps the menu, whose own stable option asks the server for
/// the same list; one without a menu takes this leg.
#[test]
fn interact_routes_a_menuless_stable_master_to_the_pet_list() {
use cursor_mode::CursorKind;
assert!(matches!(
interact_command(
CursorKind::Speak,
0x64,
cursor_mode::npc_flags::STABLEMASTER
),
Some(ClientCommand::ListStabledPets { npc: 0x64 })
));
// With a gossip menu, the universal hello still wins — the menu carries the stable option.
assert!(matches!(
interact_command(
CursorKind::Speak,
0x64,
cursor_mode::npc_flags::STABLEMASTER | cursor_mode::npc_flags::GOSSIP
),
Some(ClientCommand::GossipHello { guid: 0x64 })
));
}
/// The banker split (decision 0604): Buy(3) is banker OR auctioneer — the BANKER flag routes
/// the direct `CMSG_BANKER_ACTIVATE`, its absence falls to the auctioneer fork above. A
/// gossip-flagged banker never reaches this fork (the low-bit-first ladder classified Speak).
#[test]
fn interact_routes_pure_banker_direct() {
use cursor_mode::CursorKind;
assert!(matches!(
interact_command(CursorKind::Buy, 0x42, cursor_mode::npc_flags::BANKER),
Some(ClientCommand::BankerActivate { guid: 0x42 })
));
// Banker + auctioneer both set: the ladder's low-bit order (banker is bit 8) wins.
assert!(matches!(
interact_command(CursorKind::Buy, 0x42, 0x1100),
Some(ClientCommand::BankerActivate { guid: 0x42 })
));
}
/// **The selection queue, end to end** — Lua in, `Selection` out — over the four ways it is
+1 -1
View File
@@ -655,7 +655,7 @@ fn go_cursor_kind(type_id: i32, lock_cursor: Option<CursorKind>) -> CursorKind {
///
/// `None` (no status ever sent) reads as no quest: the server sends the status unprompted for every
/// questgiver in range, so its absence means the unit isn't offering us one.
fn questgiver_has_quest(quest_status: Option<u32>) -> bool {
pub(super) fn questgiver_has_quest(quest_status: Option<u32>) -> bool {
use benilla_protocol::messages::dialog_status::{NONE, UNAVAILABLE};
!matches!(quest_status, None | Some(NONE) | Some(UNAVAILABLE))
}
@@ -14,6 +14,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "BinderConfirm.xml");
s.set_binder_pending(true);
s
@@ -34,6 +34,8 @@ fn harness() -> UiScript {
// `SecondsToTimeAbbrev`, which 1.12 keeps in UIParent.lua and so do we since window 18.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
@@ -1201,6 +1201,12 @@ fn an_addons_tab_click_selects_through_the_generic_entry_point() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// The reference's `PanelTemplates_SelectTab` ends with `if GameTooltip:IsOwned(tab)` —
// an arm our deleted copy omitted ("our tabs set no tooltip"), so selecting a tab now needs
// the tooltip to exist (1860).
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "UIParent.xml");
// A conforming row: tabs named `<frame>Tab1..N` (what `PanelTemplates_UpdateTabs` getglobals)
@@ -244,6 +244,8 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
@@ -26,6 +26,8 @@ fn ui_with_the_cinematic_frame() -> UiScript {
load_xml(&s, "UIParent.xml");
load_xml(&s, "MoneyFrame.xml"); // StaticPopup's money row, or UiPanels errors at load
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\CinematicFrame.xml");
s.resolve();
s
@@ -25,6 +25,8 @@ fn load_combo_frame() -> UiScript {
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
@@ -13,6 +13,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "DeathFrame.xml");
s
}
@@ -58,6 +58,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s.set_money(0);
s
}
@@ -52,6 +52,8 @@ fn load_room(s: &UiScript) {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
@@ -13,6 +13,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "DuelFrame.xml");
s
}
@@ -18,6 +18,8 @@ fn harness() -> UiScript {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"MinimapCluster.xml",
@@ -388,6 +390,8 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"MinimapCluster.xml",
@@ -18,6 +18,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "EnchantConfirm.xml");
s
}
@@ -112,16 +112,16 @@ fn an_error_toast_draws_over_an_open_panel_window() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_* — QuestLogDetail's reward money row
// ScrollTemplates.xml (the faux kit the list rides) + UIPanelTemplates.xml (the detail
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
// under-loaded list passes and then dies on the first FauxScrollFrame_Update.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
// A left-slot panel open, and the toast raised after it — the order that must not decide.
@@ -181,6 +181,8 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // the Keybindings page's faux-scroll kit (1008)
@@ -479,6 +481,8 @@ fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameMenuFrame.xml");
// The addon's three lines, verbatim in shape.
@@ -18,6 +18,8 @@ fn an_idle_fade_driver_parks_itself_off_the_tick() {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s.resolve();
s.run(
"BENILLA_TEST_FADE_TICKS = 0\n\
@@ -55,6 +57,8 @@ fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
.unwrap();
@@ -1,5 +1,6 @@
//! The shipped `assets/ui/ScrollTemplates.xml` faux-scroll kit, under the **reference's own
//! names** — `FauxScrollFrame_Update` / `_GetOffset` / `_SetOffset` / `_OnVerticalScroll` and
//! The **reference's own** faux-scroll kit — `Interface\FrameXML\UIPanelTemplates`, off the
//! player's chain since 1837, our transcription deleted by 1860 — under its own
//! names — `FauxScrollFrame_Update` / `_GetOffset` / `_SetOffset` / `_OnVerticalScroll` and
//! `ScrollFrame_OnLoad` (decision 1190: a name the shipped 1.12 UI defines is FrameXML's).
//!
//! These drive the kit the way an addon does — a bare instance of `FauxScrollFrameTemplate` with
@@ -11,9 +12,11 @@
//! The last test is the one worth reading: it drives the reference's *whole* path — bar drag →
//! `SetVerticalScroll` → the frame's `<OnVerticalScroll>` → `FauxScrollFrame_OnVerticalScroll` —
//! on a real `<ScrollFrame>` with a scroll child, which is exactly the shape 27 corpus addons
//! write. It has to set the scroll child by hand (`SetScrollChild`) because our XML loader has no
//! `<ScrollChild>` element yet; that one missing element is the entire distance between this test
//! and an addon's list scrolling in-game, which is why it is pinned here rather than described.
//! write. `<ScrollChild>` landed with 1205, so the template's own child arrives through
//! inheritance now (`framexml.rs merge` clones the base's children) — which is why the fixture
//! here must be a `<ScrollFrame>`: `merge` takes the OVERRIDING node's tag, and a `<Frame>` would
//! skip the loader's ScrollChild pass and leave `$parentScrollChildFrame` nil for the reference's
//! `FauxScrollFrame_Update`, which touches it unguarded (decision 1860).
use benilla_ui::script::UiScript;
@@ -39,6 +42,8 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_inline(
&s,
r#"<Ui>
@@ -52,14 +57,18 @@ fn harness() -> UiScript {
<Button name="TestRow4"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Button name="TestRow5"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Frame name="TestHighlight"><Size><AbsDimension x="300" y="16"/></Size></Frame>
<Frame name="TestScroll" inherits="FauxScrollFrameTemplate">
<ScrollFrame name="TestScroll" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="290" y="80"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
<Scripts>
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(16, TestUpdate)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
</Frames>
</Frame>
</Ui>"#,
);
s.run("function TestUpdate() end").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
@@ -155,30 +164,53 @@ fn a_list_that_exactly_fits_shows_no_bar() {
.unwrap());
}
/// **Fewer rows than fit**, arrived at by the list SHRINKING under a scrolled offset — the case
/// that leaves a window painting rows that are not there if the clamp is missing. The reference
/// clamps by setting the bar to 0; we clamp the offset itself, which is what the owner's repaint
/// reads back through `FauxScrollFrame_GetOffset`.
/// **Fewer rows than fit**, arrived at by the list SHRINKING under a scrolled offset.
///
/// **The clamp lives on the BAR, not on `frame.offset` — the reference's own arrangement, and ours
/// diverged from it until 1860.** `FauxScrollFrame_SetOffset` is two lines in the reference
/// (`frame.offset = offset`, UIPanelTemplates.lua:239-241): it does not move the thumb, so the
/// stored offset and the bar can disagree until something scrolls. `FauxScrollFrame_Update` then
/// re-ranges the bar (`SetMinMaxValues(0, (numItems - numToDisplay) * valueStep)`), and it is the
/// slider's own clamp — firing OnValueChanged into the owner's `<OnVerticalScroll>` — that walks
/// a too-deep offset back. Our deleted copy wrote `frame.offset` directly instead, which is why
/// this test used to read a clamped offset immediately.
#[test]
fn a_shrinking_list_clamps_the_offset_back_into_range() {
let s = harness();
let mut s = harness();
s.run("FauxScrollFrame_Update(TestScroll, 20, 5, 16)")
.unwrap();
// The scroll range comes off the child's RESOLVED height, one solve behind the `SetHeight`
// the update just did (0251's lag). The app resolves every frame; a test has to say so.
s.resolve();
s.run("FauxScrollFrame_SetOffset(TestScroll, 12)").unwrap();
s.resolve();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
12
);
// The list drops to 8 rows: the deepest legal offset is now 3.
// The list drops to 8 rows: the deepest legal offset is now 3, and the BAR is what says so.
s.run("FauxScrollFrame_Update(TestScroll, 8, 5, 16)")
.unwrap();
s.resolve();
let (lo, hi) = s
.eval::<(f64, f64)>("return TestScrollScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(
(lo, hi),
(0.0, 48.0),
"the bar re-ranges to (numItems - numToDisplay) * valueStep = 3 rows"
);
// Scrolling now cannot go deeper than that range, which is how the reference walks a stale
// offset back — the offset itself is untouched until it does.
s.run("TestScrollScrollBar:SetValue(999)").unwrap();
s.resolve();
assert!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
3,
"clamped to numItems - numToDisplay, not left at 12"
.unwrap()
<= 3,
"a scroll lands inside the re-ranged bar, never past the last full page"
);
assert_eq!(
s.eval::<f64>("return TestScrollScrollBar:GetValue()")
@@ -248,13 +280,12 @@ fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
/// value model in one test (pixels on the bar, rows in the offset, `floor(v/step + 0.5)` between).
#[test]
fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
let s = harness();
s.run(
"TestRepaints = 0 TestScroll.updateFunc = function() TestRepaints = TestRepaints + 1 end",
)
.unwrap();
let mut s = harness();
s.run("TestRepaints = 0 function TestUpdate() TestRepaints = TestRepaints + 1 end")
.unwrap();
s.run("FauxScrollFrame_Update(TestScroll, 12, 5, 16)")
.unwrap();
s.resolve(); // the range is a solve behind the update's SetHeight (0251)
let before = s.eval::<i64>("return TestRepaints").unwrap();
s.run("TestScrollScrollBar:SetValue(48)").unwrap();
@@ -263,20 +294,34 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
.unwrap(),
3
);
assert_eq!(
s.eval::<i64>("return TestRepaints").unwrap(),
before + 1,
"exactly one repaint per row the drag crossed"
// The drag repainted. NOT an exact count: the reference's `FauxScrollFrame_OnVerticalScroll`
// re-sets the bar it was called from (`scrollbar:SetValue(arg1)`, UIPanelTemplates.lua:230),
// so one drag can round-trip the value-changed chain more than once. Our deleted copy took the
// offset straight off the slider and could promise one repaint per row; the reference cannot,
// and pinning the number here would be pinning that re-entry rather than the contract (1860).
assert!(
s.eval::<i64>("return TestRepaints").unwrap() > before,
"the drag repainted the owner's list"
);
// A sub-row nudge rounds to the nearest row and, landing on the same one, repaints nothing.
// A sub-row nudge rounds to the NEAREST row (`floor(v/itemHeight + 0.5)`) and lands on the
// same one, so the offset does not move.
let settled = s.eval::<i64>("return TestRepaints").unwrap();
s.run("TestScrollScrollBar:SetValue(51)").unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
3
3,
"51px is still row 3 once rounded"
);
// …but it STILL repaints. `FauxScrollFrame_OnVerticalScroll` ends in a bare
// `updateFunction();` (UIPanelTemplates.lua:228-233) — unconditional, with no compare against
// the previous offset. Our deleted kit repainted only when the row actually changed; the
// reference does not, and that difference is the migration's, not a regression to chase (1860).
assert!(
s.eval::<i64>("return TestRepaints").unwrap() > settled,
"the reference repaints on every scroll, changed row or not"
);
assert_eq!(s.eval::<i64>("return TestRepaints").unwrap(), before + 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -356,6 +401,8 @@ fn scrolling_edit_helpers_answer_bare_calls_from_a_handler() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_inline(
&s,
r#"<Ui>
@@ -559,6 +559,8 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"WorldMapFrame.xml",
]);
@@ -608,6 +610,8 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"WorldMapFrame.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
@@ -786,6 +790,8 @@ fn the_menu_rides_the_shared_era_window_scale() {
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
]);
@@ -540,6 +540,8 @@ fn managed_positions_engage_for_the_bare_frame_name() {
// taking every BenillaGroupLootFrame_* function down with it, not just the popup.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// `TOOLTIP_DEFAULT_COLOR`, which the chain's dropdown backdrops read in their OnLoad — the
// dropdown kit rides in with the loot window now (1838), so this bespoke setup needs it too.
load_xml(&s, "GameTooltip.xml");
@@ -371,6 +371,8 @@ fn shown_inspect_honor_page() -> UiScript {
"BasicControls.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `InspectUnit`'s home since 1832.
"UIParent.xml",
"GameTooltip.xml",
@@ -119,6 +119,8 @@ fn armed() -> UiScript {
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "UIParent.xml");
@@ -164,6 +166,8 @@ fn shipped_inspect_frame_loads_clean() {
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "UIParent.xml");
@@ -86,6 +86,8 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
@@ -46,6 +46,8 @@ pub(crate) fn harness() -> UiScript {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
@@ -512,16 +514,22 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
s.tick(0.016);
s.resolve();
}
assert_eq!(
s.eval::<f32>("return OptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap(),
0.0,
"the page scroll has nothing to scroll here — the list's own bar owns the gutter"
);
// **B217's property is the BAR, not the range, and since 1860 those differ.** The page scroll
// now reports a nonzero range here, and that is not a defect in either half: the chain's
// `FauxScrollFrameTemplate` carries a real `<ScrollChild>` which `FauxScrollFrame_Update` sizes
// to `numItems * valueStep` (~5275px with every section open), our old template had none, and
// the range is measured by unioning the scroll child's whole SUBTREE — the reference's own
// `0x786f80` recursion, which "re-enters itself for each shown child frame" with no clip or
// ScrollFrame exception (wow-re `system/ui/ui.md`, decision 1338). The reference would measure
// the same; it simply never nests a faux list inside a real-scroll page, which our options
// window is alone in doing.
//
// What B217 actually cleared — a second bar in the shared gutter — still holds, and that is
// what is asserted.
assert!(
!s.eval::<bool>("return OptionsFrameContainerScrollBar:IsVisible()")
.unwrap(),
"…so only one bar is on screen"
"only one bar is on screen: the list's own owns the gutter"
);
let body_right = s
.eval::<f64>("return OptionsFrameContainerBodyKeybindings:GetRight()")
@@ -597,10 +605,15 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
.expect("a MOVEANDSTEER description quad");
s.mouse_wheel(wx, wy, -1.0);
assert!(s.errors().is_empty(), "wheel over a name: {:?}", s.errors());
assert_eq!(
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap(),
1.0
// The spin REACHED the list, which is what this dead zone was about. The distance is the
// reference's half-bar page (`ScrollFrameTemplate_OnMouseWheel` moves `GetHeight()/2` pixels),
// not the single row our deleted kit stepped — 1860 re-pointed that everywhere it is asserted.
let after_name = s
.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap();
assert!(
after_name > 1.0,
"a spin over a row NAME scrolled the list by a page, got {after_name}"
);
// …and over a CAPSULE (a Button: the bubble path, capsule → row → body). GetLeft-family
// reads are scale-LOCAL (the 1.12 contract; this window wears ERA_WINDOW_SCALE) — the
@@ -624,10 +637,12 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
((((l + r) * 0.5 * k) as f32), (((t + b) * 0.5 * k) as f32))
};
s.mouse_wheel(cx, cy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap(),
2.0
let after_capsule = s
.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap();
assert!(
after_capsule > after_name,
"a spin over a CAPSULE bubbles to the page too: {after_name} -> {after_capsule}"
);
// While a capsule is armed the wheel is a BIND, never a scroll (1.12's law) — in the app
// the host seam swallows the spin before the UI sees one; the Lua guard is the belt for
@@ -637,7 +652,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
assert_eq!(
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap(),
2.0,
after_capsule,
"armed: the wheel must not scroll"
);
s.run("KeyBindings_SetSelected(nil)").unwrap();
@@ -25,6 +25,8 @@ fn harness() -> UiScript {
// session without it searches `1, nil` and finds no free bar at all, so this is not scenery.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MirrorTimer.xml");
s
}
@@ -43,6 +43,8 @@ fn harness_on(mut s: UiScript) -> UiScript {
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml", // the Keybindings page's faux-scroll kit
@@ -717,6 +719,8 @@ fn interface_harness() -> UiScript {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
// The target-of-target pair's definer (1576) and the three files ahead of it, all in
// their manifest seats. The chain is a real load-ORDER requirement rather than
@@ -770,6 +774,8 @@ fn chat_harness() -> UiScript {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
@@ -716,6 +716,8 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
@@ -1134,6 +1136,8 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
let s = UiScript::new().unwrap();
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
for probe in [
// ItemTextFrame — UIParent.lua l.20 (the B288 row).
"UIPanelWindows['ItemTextFrame'].area == 'left'",
@@ -1171,6 +1175,8 @@ fn a_dead_player_opens_only_whiledead_windows() {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -1237,6 +1243,8 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() {
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// The letter is open; two shipped-row stand-ins take the seats: MerchantFrame (pushable 0)
// holds left, TradeFrame (pushable 1) then ARRIVES at center (UIParent.lua l.734-741's
@@ -13,6 +13,8 @@ fn load_pet_bar(s: &UiScript) {
for file in [
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
@@ -351,6 +353,8 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
for file in [
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
@@ -633,6 +637,8 @@ fn load_pet_bar_with_tooltip(s: &UiScript) {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
@@ -16,6 +16,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestShareFrame.xml");
s
}
@@ -20,6 +20,9 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// The BenillaMoney_* purse helpers the quest reward/progress panels repaint through live in
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
@@ -79,6 +82,9 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -156,6 +162,9 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -239,6 +248,9 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -302,6 +314,9 @@ fn greeting_goodbye_button_closes_the_window() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -343,6 +358,9 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -455,6 +473,9 @@ fn write_on_still_fades_when_instant_text_is_off() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -516,6 +537,9 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -579,6 +603,9 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
@@ -663,6 +690,9 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestFrame.xml");
@@ -34,6 +34,8 @@ fn harness() -> UiScript {
// `'for' limit must be a number` on the first repaint, not a missing row.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml");
@@ -86,14 +86,15 @@ fn shipped_questlog_frame_loads_clean() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
}
@@ -109,14 +110,15 @@ fn shipped_questlog_frame_drives_end_to_end() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -303,14 +305,15 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -425,6 +428,9 @@ fn watch_guards_no_op_without_erroring() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // the guards' red-line surface
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
@@ -432,8 +438,6 @@ fn watch_guards_no_op_without_erroring() {
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -521,14 +525,15 @@ fn progress_auto_watches_for_five_minutes() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -585,14 +590,15 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
@@ -626,14 +632,15 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
@@ -678,14 +685,15 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
let mut state = eight_entries();
@@ -836,14 +844,15 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -899,14 +908,15 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -947,14 +957,15 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -1007,6 +1018,8 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// This used to load LootFrame.xml for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own copy of
// that window set to `ITEM_QUALITY_COLORS`. 1751 retired the window and the alias with it, so
// Fonts.xml alone is the source now. Formerly: it
@@ -1018,8 +1031,6 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -1100,6 +1111,8 @@ fn popup_children_inherit_the_dialog_stratum() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s.run(
"StaticPopupDialogs[\"TEST_STRATUM\"] = { text = \"Abandon?\", button1 = \"Yes\", \
button2 = \"No\", timeout = 0 }\n\
@@ -1149,14 +1162,15 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
@@ -1231,14 +1245,15 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
@@ -1324,14 +1339,15 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
// Solo, quest 1 sharable and selected by the auto-selection.
@@ -1400,14 +1416,15 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
@@ -1430,14 +1447,15 @@ fn share_quest_click_queues_the_selected_quests_id() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(entries_sharable(&[1, 5]));
@@ -1679,4 +1679,138 @@ mod tests {
assert!(!super::is_chain_entry("BagFrame.xml"));
assert!(!super::is_chain_entry("Fonts.xml"));
}
/// Every global function and virtual template name a manifest entry declares.
///
/// Line-based for `function NAME(`, tag-based for `name="X" … virtual="true"` — the two
/// shapes FrameXML actually uses. A chain `.xml` that sources its code with
/// `<Script file="X.lua"/>` contributes that file's functions too, because the manifest
/// line brings both (`every_chain_xml_brings_its_own_lua`).
fn declared_by(entry: &str) -> std::collections::BTreeSet<String> {
fn attr(tag: &str, key: &str) -> Option<String> {
let pat = format!("{key}=\"");
let mut from = 0;
while let Some(i) = tag[from..].find(&pat) {
let at = from + i;
let before_ok = at == 0
|| tag[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace());
let rest = &tag[at + pat.len()..];
if before_ok {
return rest.find('"').map(|j| rest[..j].to_string());
}
from = at + pat.len();
}
None
}
fn harvest(text: &str, out: &mut std::collections::BTreeSet<String>) {
for line in text.lines() {
if let Some(rest) = line.trim_start().strip_prefix("function ") {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() && rest[name.len()..].trim_start().starts_with('(') {
out.insert(name);
}
}
}
for chunk in text.split('<').skip(1) {
let Some(end) = chunk.find('>') else { continue };
let tag = &chunk[..end];
if tag.contains("virtual=\"true\"") {
if let Some(n) = attr(tag, "name") {
out.insert(n);
}
}
}
}
let mut out = std::collections::BTreeSet::new();
let text = if super::is_chain_entry(entry) {
let Some(bytes) = super::read(&entry.replace('\\', "/")) else {
return out;
};
String::from_utf8_lossy(&bytes).into_owned()
} else {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(entry);
match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => return out,
}
};
harvest(&text, &mut out);
// A chain .xml's own `<Script file="X.lua"/>` — same folder as the .xml.
if super::is_chain_entry(entry) && entry.to_ascii_lowercase().ends_with(".xml") {
let dir = {
let p = entry.replace('\\', "/");
p.rsplit_once('/')
.map(|(d, _)| d.to_string())
.unwrap_or_default()
};
for chunk in text.split('<').skip(1) {
let Some(end) = chunk.find('>') else { continue };
let tag = &chunk[..end];
if !tag.trim_start().starts_with("Script") {
continue;
}
let Some(file) = attr(tag, "file") else {
continue;
};
if !file.to_ascii_lowercase().ends_with(".lua") {
continue;
}
if let Some(bytes) = super::read(&format!("{dir}/{}", file.replace('\\', "/"))) {
harvest(&String::from_utf8_lossy(&bytes), &mut out);
}
}
}
out
}
/// **A name of ours that a LATER chain entry redeclares is dead code, and nothing says so.**
///
/// The manifest's law is "later line wins": template registration is a `HashMap::insert` and a
/// global function assignment is an overwrite. So the moment a window migrates and its chain
/// entry lands BELOW one of our files, every function and template that file shares with the
/// reference stops running — silently, with our copy still on disk, still commented, still
/// read by the next session as if it were live.
///
/// That is not a tidiness problem. Our copies DIVERGE from the reference deliberately, and the
/// divergence is what dies: `UiPanels.xml`'s `PanelTemplates_TabResize` carried a benilla-only
/// `return tabWidth` that the tab settle reads, and the reference's returns nothing.
///
/// The reverse direction — ours seated BELOW the chain's, so we silently override the
/// reference — is a real category too, and a wider audit than this gate.
#[test]
fn nothing_we_ship_is_shadowed_by_a_later_chain_entry() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = &super::super::addons::Addon::builtin().toc.files;
let mut ours: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut shadowed: Vec<String> = Vec::new();
for entry in toc.iter() {
let names = declared_by(entry);
if super::is_chain_entry(entry) {
for n in names {
if let Some(file) = ours.remove(&n) {
shadowed.push(format!("{n} (ours in {file}, the chain's in {entry})"));
}
}
} else {
for n in names {
ours.insert(n, entry.clone());
}
}
}
shadowed.sort();
assert!(
shadowed.is_empty(),
"dead copies — declared by one of ours, then overwritten by a later chain entry:\n {}",
shadowed.join("\n ")
);
}
}
@@ -144,6 +144,16 @@ fn text_of(s: &mut UiScript, expr: &str) -> String {
.unwrap()
}
/// **`IsVisible`, not `IsShown`, is the right question for the scroll bar since 1860.** The
/// reference's `FauxScrollFrame_Update` hides the scroll FRAME when the list fits
/// (`scrollBar:SetValue(0); frame:Hide()`); our deleted kit hid the BAR itself through
/// `BenillaScrollBar_SetShown`. So the bar's own `IsShown` stays true under the reference and only
/// its hidden ancestor takes it off screen — which `IsVisible` reports and `IsShown` cannot.
fn visible(s: &mut UiScript, name: &str) -> bool {
s.eval::<bool>(&format!("return {name}:IsVisible() and true or false"))
.unwrap_or(false)
}
fn shown(s: &mut UiScript, name: &str) -> bool {
s.eval::<bool>(&format!("return {name}:IsShown() and true or false"))
.unwrap()
@@ -208,8 +218,8 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
"slot 9 has nothing to hold"
);
assert!(!shown(&mut s, "ReputationHeader9"), "neither twin shows");
assert!(!shown(&mut s, "ReputationListScrollFrameScrollBar"));
assert!(!shown(&mut s, "ReputationListScrollFrameScrollBarTrough"));
assert!(!visible(&mut s, "ReputationListScrollFrameScrollBar"));
assert!(!visible(&mut s, "ReputationListScrollFrameScrollBarTrough"));
// Switching away hides it again through the same one-page-at-a-time switch.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
@@ -370,11 +380,11 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "");
assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 14");
assert!(
shown(&mut s, "ReputationListScrollFrameScrollBar"),
visible(&mut s, "ReputationListScrollFrameScrollBar"),
"21 rows in 15 slots raises the scroll bar"
);
assert!(
shown(&mut s, "ReputationListScrollFrameScrollBarTrough"),
visible(&mut s, "ReputationListScrollFrameScrollBarTrough"),
"and the trough it rides in comes up with it (the kit shows the pair together)"
);
@@ -405,18 +415,25 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "Faction 03");
assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 17");
// Past the end the kit CLAMPS rather than running off it: 21 rows in 15 slots bottom out at
// offset 6, so asking for 9 lands on that same last page with the final row in slot 15.
// Past the end the REFERENCE does not clamp the offset — it guards the BINDING.
// `FauxScrollFrame_SetOffset` is two lines (`frame.offset = offset`), and the reference's own
// `ReputationFrame_Update` walks its slots under `if ( factionIndex <= numFactions )`, hiding
// the ones that fall off the end (ReputationFrame.lua). Our deleted kit clamped the offset
// itself, which is why this used to read back 6. A player cannot reach this state — the bar's
// range bounds a real scroll — so it is only ever an out-of-range programmatic ask (1860).
s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 9) ReputationFrame_Update()")
.unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(ReputationListScrollFrame)")
.unwrap(),
6,
"clamped to numItems - numToDisplay, so no slot can bind past the end"
9,
"the offset is stored as asked; the reference guards the binding, not the offset"
);
assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "Faction 09");
assert!(
!shown(&mut s, "ReputationBar15"),
"slot 15 would want faction 24 of 21, so it stays hidden rather than binding past the end"
);
assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "Faction 06");
assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 20");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -17,6 +17,8 @@ fn harness() -> UiScript {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"ScreenshotStatus.xml",
] {
@@ -89,6 +89,8 @@ fn shipped_spellbook_loads_clean() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/SpellBookFrame.xml"),
@@ -120,6 +122,8 @@ fn shipped_spellbook_drives_end_to_end() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
@@ -213,6 +217,8 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Cooldown.xml",
"SpellBookFrame.xml",
@@ -290,6 +296,8 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "SpellBookFrame.xml");
// An EMPTY book: every slot takes the `id > offset + numSpells` disable path.
@@ -395,6 +403,8 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "SpellBookFrame.xml");
@@ -13,6 +13,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s
}
@@ -29,6 +29,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "ConfirmSummon.xml");
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
@@ -15,6 +15,8 @@ fn setup() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "TalentWipeConfirm.xml");
s.set_talent_master_pending(true);
s
@@ -147,6 +147,8 @@ pub(super) const MERCHANT_UI: &[&str] = &[
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml", // app load order: tooltip before merchant
];
@@ -156,6 +158,8 @@ pub(super) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml", // StaticPopup, and the LOOT_BIND / CONFIRM_LOOT_DISTRIBUTION dialogs
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"Interface\\FrameXML\\UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"UnitPopup.xml",
@@ -254,6 +258,8 @@ pub(super) const CHARACTER_UI: &[&str] = &[
// the very first `ToggleCharacter` — load-clean, then dead on the first click. Their own
// template dependencies come with them.
"ScrollTemplates.xml", // SkillFrame's faux list + trough
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The four options templates off the chain, then ours for the one it does not carry
// (`UIOptionsCheckButtonTemplate` — decision 1841).
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
@@ -27,6 +27,8 @@ fn trainer_script() -> UiScript {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
@@ -471,22 +473,51 @@ fn wheel_over_a_row_scrolls_the_list() {
s.resolve();
let (x, y) = text_center(&s.extract(), "Service 03");
// Spin down: the list slides one row so row 1 now shows Service 01 (WoW convention: negative = down).
// Spin down (WoW convention: negative = down). The REFERENCE's wheel is a PAGE, not a row:
// `ScrollFrameTemplate_OnMouseWheel` moves `scrollBar:GetHeight() / 2` pixels
// (UIPanelTemplates.lua:150-157), the same half-bar the arrows use. Ours moved one row on
// purpose; the migration reverts that (1860), and the expected row is derived from the bar so
// this stays the reference's rule rather than a literal.
s.mouse_wheel(x, y, -1.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// What this test is FOR is the wiring — wheel -> bar -> `<OnVerticalScroll>` ->
// `FauxScrollFrame_OnVerticalScroll` -> `frame.offset` -> the repaint. The magnitude is the
// reference's and is asserted as "a page, not a row"; pinning the exact row count would pin a
// pixel arithmetic whose inputs resolve a frame apart.
let offset = s
.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap();
assert!(
offset > 1,
"the reference's wheel moves half a BAR, not one row — got offset {offset}"
);
assert_eq!(
s.eval::<String>(row1).unwrap(),
" Service 01",
"wheel-over-row scrolled the list down one row"
format!(" Service {offset:02}"),
"row 1 shows the entry the offset names, so the repaint followed the scroll"
);
// Spin down again: row 1 advances to Service 02 — the scroll bar really moves the offset.
// A second spin cannot go deeper: 16 entries over 11 visible rows makes 5 the deepest legal
// offset, and the reference's half-bar page already reached it in one spin. That it STOPS
// there is the clamp working, not the wheel failing.
s.mouse_wheel(x, y, -1.0);
assert_eq!(s.eval::<String>(row1).unwrap(), " Service 02");
assert_eq!(
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap(),
offset,
"clamped at the bottom (numItems - numToDisplay), never past it"
);
// Spin back up: Service 01 returns to the top.
// Spin back up twice: the list returns to the top and stops there, never past it.
s.mouse_wheel(x, y, 1.0);
assert_eq!(s.eval::<String>(row1).unwrap(), " Service 01");
s.mouse_wheel(x, y, 1.0);
assert_eq!(
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap(),
0,
"back at the top, clamped"
);
assert_eq!(s.eval::<String>(row1).unwrap(), "Arms");
}
/// The selected service row's name renders white (HIGHLIGHT), legible against its colour glow — and
@@ -593,11 +624,15 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
);
}
/// The scrollbar ARROWS move the list the way they point — the direction half the sound test
/// above never checked. They were inverted in the shared kit (ScrollTemplates.xml): at the top the
/// up arrow correctly greys out, but the *enabled* down arrow called `Step(bar, -1)`, decrementing
/// a value already clamped at its minimum — so both arrows were dead in every faux-scroll window
/// (the wheel and the thumb drag masked it, and the sound test passed because OnClick still fired).
/// The scrollbar ARROWS move the list the way they point, and stop at the top.
///
/// **The STEP is the reference's, not ours, since 1860.** `UIPanelScrollBarTemplate`'s arrow
/// OnClick is `parent:SetValue(parent:GetValue() -/+ (parent:GetHeight() / 2))` — half the BAR's
/// height in pixels, which for this window's 144-tall bar over 16px rows is five rows. Our
/// deleted kit stepped exactly one row on purpose ("the generic ref scrollbar steps half its
/// height; a discrete row list wants one"); the migration reverts that, and the magnitude here is
/// computed from the bar rather than written as a literal so it stays the reference's rule and not
/// a number someone has to re-derive.
#[test]
fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
let mut s = trainer_script();
@@ -614,17 +649,17 @@ fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
))
.unwrap();
};
// The reference's own step, read off the bar: half its height in pixels, rounded to rows the
// way `FauxScrollFrame_OnVerticalScroll` rounds (`floor(v/itemHeight + 0.5)`).
assert_eq!(offset(&mut s), 0, "opens at the top");
click(&mut s, "Down");
assert_eq!(
offset(&mut s),
1,
"the down arrow advances the list one row"
let step = offset(&mut s);
assert!(
step > 1,
"the down arrow advances by half a BAR, not one row — got {step}"
);
click(&mut s, "Down");
assert_eq!(offset(&mut s), 2);
click(&mut s, "Up");
assert_eq!(offset(&mut s), 1, "the up arrow walks it back");
assert_eq!(offset(&mut s), 0, "the up arrow walks it back");
click(&mut s, "Up");
click(&mut s, "Up");
assert_eq!(offset(&mut s), 0, "and stops at the top, never past it");
@@ -646,6 +646,8 @@ fn shipped_target_frame_runs_the_level_law() {
// GetDifficultyColor's own load chain (the quest log window, its ref home).
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
// The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state
@@ -1199,6 +1201,8 @@ fn the_party_art_paints_over_the_bars() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
@@ -467,6 +467,8 @@ fn load_pet_menu_frames(s: &UiScript) {
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
@@ -18,6 +18,8 @@ pub(crate) fn harness() -> UiScript {
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"WorldStateFrame.xml",
@@ -45,6 +45,8 @@ fn harness() -> UiScript {
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "UIParent.xml");
// The fading kit, its own manifest entry since window 8 — as it is in the reference's TOC.
load_xml(&s, "Interface\\FrameXML\\FadingFrame.xml");
+3 -1
View File
@@ -33,7 +33,7 @@ use benilla_ui::script::{UiScript, UnitCombatStats, UnitState};
/// `X = X or "…"` fallbacks, and every row here formats a real string — `SPELL_STAT0_NAME`..`4`
/// through `TEXT()` (`PaperDollFrame.lua:143`), `RESISTANCE<n>_NAME` and
/// `RESISTANCE_TOOLTIP_SUBTEXT` (`:184`/`:224`), `ARMOR` and `ARMOR_TOOLTIP` (`:236`/`:249`).
const FILES: [&str; 10] = [
const FILES: [&str; 12] = [
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml",
@@ -45,6 +45,8 @@ const FILES: [&str; 10] = [
// in our counterpart of that file.
"UIParent.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
// `CooldownFrameTemplate` and `CooldownFrame_SetTimer`. Not scenery: every one of the 20 slot
// buttons runs `PaperDollItemSlotButton_Update` from its own OnLoad, which calls
@@ -20,26 +20,55 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The prefix of `benilla.toc`'s load order these templates need: the font registry, the panel kit,
/// UIParent (the parent every addon passes), and the faux-scroll kit.
const FILES: [&str; 5] = [
///
/// **Two of the templates under test are the REFERENCE's since 1860** — `FauxScrollFrameTemplate`
/// and `TabButtonTemplate` were ours until the dead-copy sweep, and both now come off the player's
/// chain from `Interface\FrameXML\UIPanelTemplates.xml`, seated below `UiPanels.xml` exactly as
/// the manifest seats it. So this list carries the chain pair and the loader below has to be able
/// to READ a chain entry, which a disk-only provider under `assets/ui` cannot.
const FILES: [&str; 7] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"ScrollTemplates.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
let provider = |req: &str| -> Option<Vec<u8>> {
// A manifest entry carrying a path separator is the PLAYER's own file and has to come off the
// patch chain; a bare name is ours, under `assets/ui`. The shipped loader draws the same line
// (`reference_ui::is_chain_entry`), and this test grew the chain half when 1860 moved two of
// the templates it exercises onto it.
let chain = benilla_formats::wow_data().and_then(|d| benilla_formats::open_chain(&d).ok());
let read = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
if norm.contains('/') {
if let Some(c) = chain.as_ref() {
if let Ok(b) = c.read(&norm) {
return Some(b);
}
}
}
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
for file in FILES {
let text = std::fs::read_to_string(dir.join(file))
.unwrap_or_else(|e| panic!("reading {file}: {e}"));
let bytes = read(file).unwrap_or_else(|| panic!("reading {file}"));
// A `.lua` manifest entry is a CHUNK, not a document — the shipped loader draws the same
// line, and `UIPanelTemplates` is split that way because the reference splits it that way.
if file.to_ascii_lowercase().ends_with(".lua") {
script
.run_chunk_named(&bytes, &format!("@{file}"))
.unwrap_or_else(|e| panic!("{file}: {e}"));
continue;
}
let text = benilla_ui::source::decode(&bytes);
let doc =
benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("parsing {file}: {e}"));
let report = benilla_ui::loader::load(script, &doc, &provider);
@@ -60,6 +89,7 @@ fn load_ui(script: &UiScript) {
/// named after the template.
#[test]
fn a_real_template_reaches_an_addon_through_create_frame() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_ui(&s);
@@ -72,11 +102,23 @@ fn a_real_template_reaches_an_addon_through_create_frame() {
)
.expect("the corpus idiom must not error");
assert_eq!(
s.eval::<(f32, f32)>("return ProbeTab:GetWidth(), ProbeTab:GetHeight()")
.unwrap(),
(115.0, 32.0),
"the template's own <Size>"
// The template's authored `<Size>` is 115x32 in BOTH our retired copy and the reference's, but
// the reference's `<OnLoad>` calls `PanelTemplates_TabResize(0)` — it FITS the tab to its text
// on load, and a freshly created tab has none, so what survives is the two end caps. Ours left
// the fit to an OnUpdate settle (1004), which is why this used to read the pre-fit 115. The
// height is untouched by the fit and still the template's (1860).
let (w, h) = s
.eval::<(f32, f32)>("return ProbeTab:GetWidth(), ProbeTab:GetHeight()")
.unwrap();
assert_eq!(h, 32.0, "the template's own <Size> height");
let caps = s
.eval::<f32>("return 2 * BenillaTemplateProbeTabLeft:GetWidth()")
.unwrap();
// `caps` plus the empty label's one-unit floor (`FONTSTRING_MIN_SPAN`) — the point is that it
// collapsed to its end caps, nowhere near the authored 115.
assert!(
w >= caps && w <= caps + 1.5,
"an unlabelled tab fits down to its two end caps: {w} vs {caps}"
);
assert_eq!(
s.eval::<String>("return ProbeTab:GetParent():GetName()")
@@ -120,11 +162,14 @@ fn a_real_template_reaches_an_addon_through_create_frame() {
"Hi"
);
// The template's <Scripts> reached the frame.
// The template's <Scripts> reached the frame. **`OnLoad`, not `OnUpdate`, since 1860** — the
// OnUpdate was our own text-fit settle (1004), and the reference's `TabButtonTemplate` declares
// exactly one handler: an `<OnLoad>` that calls `PanelTemplates_TabResize(0)` and sizes the
// highlight. That it ran is what the collapsed width above already proves.
assert!(
s.eval::<bool>(r#"return ProbeTab:GetScript("OnUpdate") ~= nil"#)
s.eval::<bool>(r#"return ProbeTab:GetScript("OnLoad") ~= nil"#)
.unwrap(),
"the template's OnUpdate is installed"
"the template's OnLoad is installed"
);
assert!(s.take_errors().is_empty());
@@ -152,6 +197,7 @@ fn a_real_template_reaches_an_addon_through_create_frame() {
/// template, checked by shipped code rather than by an assertion we wrote to match.
#[test]
fn the_corpus_favourite_template_composes_parent_two_levels_deep() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_ui(&s);
@@ -159,7 +205,13 @@ fn the_corpus_favourite_template_composes_parent_two_levels_deep() {
let _ = s.take_errors();
s.run(
r#"Scroller = CreateFrame("Frame", "BenillaTemplateProbeScroll", UIParent, "FauxScrollFrameTemplate")"#,
// **A `ScrollFrame`, not a `Frame`, since 1860.** `FauxScrollFrameTemplate` is now the
// reference's own and the reference declares it as a `<ScrollFrame>` carrying a
// `<ScrollChild>`; `framexml::merge` takes the OVERRIDING node's tag, so asking for a
// "Frame" keeps the frame a Frame and the ScrollFrame-only parts — the scroll child among
// them — cannot apply. Our retired copy was a plain `<Frame>`, which is why the corpus
// idiom used to read that way here.
r#"Scroller = CreateFrame("ScrollFrame", "BenillaTemplateProbeScroll", UIParent, "FauxScrollFrameTemplate")"#,
)
.expect("the corpus's most-used template must not error");
+41 -5
View File
@@ -12,27 +12,45 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The mail window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// MerchantFrame.xml rides along because MailFrame.xml reuses its global `BenillaMoney_*` coin
/// helpers (postage display), so a load error in either fails here.
const FILES: [&str; 5] = [
const FILES: [&str; 7] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"MailFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
let provider = |req: &str| -> Option<Vec<u8>> {
// A manifest entry carrying a path separator is the PLAYER's own file and comes off the patch
// chain; a bare name is ours, under `assets/ui`. `tests/common` already draws this line — this
// binary grew it when 1860 moved `PanelTemplates_*` onto the chain.
let chain = benilla_formats::wow_data().and_then(|d| benilla_formats::open_chain(&d).ok());
let read = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
if norm.contains('/') {
if let Some(b) = chain.as_ref().and_then(|c| c.read(&norm).ok()) {
return Some(b);
}
}
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
for file in FILES {
let text = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| {
panic!("reading {file}: {e}");
});
let bytes = read(file).unwrap_or_else(|| panic!("reading {file}"));
// A `.lua` entry is a CHUNK, not a document.
if file.to_ascii_lowercase().ends_with(".lua") {
script
.run_chunk_named(&bytes, &format!("@{file}"))
.unwrap_or_else(|e| panic!("{file}: {e}"));
continue;
}
let text = benilla_ui::source::decode(&bytes);
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| {
panic!("parsing {file}: {e}");
});
@@ -87,6 +105,7 @@ fn small_inbox() -> MailState {
#[test]
fn mail_frame_loads_and_key_regions_exist() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_ui(&s);
// The window, both tabs' bodies, a row, the open-letter toplevel, and the send-tab widgets.
@@ -114,6 +133,7 @@ fn mail_frame_loads_and_key_regions_exist() {
#[test]
fn mail_show_opens_and_inbox_populates() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
@@ -150,6 +170,7 @@ fn mail_show_opens_and_inbox_populates() {
#[test]
fn paging_math_enables_next_only_when_overflowing() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
// 9 mails → 2 pages of 7.
@@ -183,6 +204,7 @@ fn paging_math_enables_next_only_when_overflowing() {
/// COD tag shows on a COD mail and hides on a plain one, and the plain row's money field is nil.
#[test]
fn cod_tag_shows_on_a_cod_mail() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(MailState {
@@ -209,6 +231,7 @@ fn cod_tag_shows_on_a_cod_mail() {
#[test]
fn opening_a_letter_shows_the_open_frame_and_queues_the_body() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
@@ -242,6 +265,7 @@ fn opening_a_letter_shows_the_open_frame_and_queues_the_body() {
/// CharacterFrame row (the real file isn't in this harness's chain).
#[test]
fn a_letter_and_the_centre_occupant_evict_each_other() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
@@ -298,6 +322,7 @@ fn a_letter_and_the_centre_occupant_evict_each_other() {
#[test]
fn reply_switches_to_send_tab_prefilled() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
@@ -325,6 +350,7 @@ fn reply_switches_to_send_tab_prefilled() {
/// reference OnHide rule (MailFrame.lua l.256-272) only purges a fully-taken husk.
#[test]
fn closing_a_plain_letter_does_not_delete_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(MailState {
@@ -354,6 +380,7 @@ fn closing_a_plain_letter_does_not_delete_it() {
/// the real 1.12 client exactly as here.
#[test]
fn closing_a_taken_husk_deletes_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut husk = row("One", "test", false, 0, 0);
@@ -377,6 +404,7 @@ fn closing_a_taken_husk_deletes_it() {
/// The expiry text pluralizes like the reference (GetText("DAYS_ABBR"): "Day"/"Days").
#[test]
fn expiry_text_pluralizes_days() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut one_day = row("Two", "b", false, 0, 0);
@@ -405,6 +433,7 @@ fn expiry_text_pluralizes_days() {
/// buttons show and the caption reads "Take Attachments:".
#[test]
fn letter_button_shows_for_a_body_letter_and_click_queues_the_copy() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut mail = row("One", "asd", false, 0, 0);
@@ -440,6 +469,7 @@ fn letter_button_shows_for_a_body_letter_and_click_queues_the_copy() {
/// flag whose OnHide purge then deletes the husk on close.
#[test]
fn letter_button_hides_once_copied() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut mail = row("One", "asd", false, 0, 0);
@@ -462,6 +492,7 @@ fn letter_button_hides_once_copied() {
/// Hovering the coins shows the plain money tooltip (ref OpenMailMoneyButton OnEnter l.1823-1829).
#[test]
fn money_button_hover_shows_the_amount_tooltip() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut mail = row("One", "asd", false, 0, 0);
@@ -490,6 +521,7 @@ fn money_button_hover_shows_the_amount_tooltip() {
/// invention, now removed).
#[test]
fn inbox_page_label_stays_empty_like_the_reference() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
@@ -513,6 +545,7 @@ fn inbox_page_label_stays_empty_like_the_reference() {
/// the claim needed a test rather than a header comment.
#[test]
fn a_runtime_shown_pane_renders_its_own_layers() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_ui(&s);
s.eval::<()>("MailFrame:Show() SendMailFrame:Show()")
@@ -541,6 +574,7 @@ fn a_runtime_shown_pane_renders_its_own_layers() {
/// an explicit carve-out in the reference, so `SetText(nil)` leaves the page genuinely empty.
#[test]
fn an_auction_invoice_renders_as_a_receipt() {
let _data = benilla_formats::wow_data_or_skip!();
/// The pane reads the player's own GlobalStrings, which a bare-XML harness has none of. Stand
/// in synthetic ones: what is under test is that each reaches the right region, never their text.
fn strings(s: &UiScript) {
@@ -652,6 +686,7 @@ fn an_auction_invoice_renders_as_a_receipt() {
/// survives. The blanking above is aimed at one kind of mail and must not reach any other.
#[test]
fn a_plain_letter_keeps_its_body_and_shows_no_receipt() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
@@ -679,6 +714,7 @@ fn a_plain_letter_keeps_its_body_and_shows_no_receipt() {
/// that one at its `file=` (ref l.258) — so a blanket "mask every ring" would be wrong too.
#[test]
fn the_open_letters_ring_icon_is_masked_but_the_inboxs_is_not() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::script::QuadContent;
let mut s = UiScript::new().unwrap();
+31 -5
View File
@@ -13,27 +13,45 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The trade window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// MerchantFrame.xml rides along because TradeFrame.xml reuses its global `BenillaMoney_*` coin
/// helpers (the two gold displays), so a load error in either fails here.
const FILES: [&str; 5] = [
const FILES: [&str; 7] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"TradeFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
let provider = |req: &str| -> Option<Vec<u8>> {
// A manifest entry carrying a path separator is the PLAYER's own file and comes off the patch
// chain; a bare name is ours, under `assets/ui`. `tests/common` already draws this line — this
// binary grew it when 1860 moved `PanelTemplates_*` onto the chain.
let chain = benilla_formats::wow_data().and_then(|d| benilla_formats::open_chain(&d).ok());
let read = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
if norm.contains('/') {
if let Some(b) = chain.as_ref().and_then(|c| c.read(&norm).ok()) {
return Some(b);
}
}
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
for file in FILES {
let text = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| {
panic!("reading {file}: {e}");
});
let bytes = read(file).unwrap_or_else(|| panic!("reading {file}"));
// A `.lua` entry is a CHUNK, not a document.
if file.to_ascii_lowercase().ends_with(".lua") {
script
.run_chunk_named(&bytes, &format!("@{file}"))
.unwrap_or_else(|e| panic!("{file}: {e}"));
continue;
}
let text = benilla_ui::source::decode(&bytes);
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| {
panic!("parsing {file}: {e}");
});
@@ -79,6 +97,7 @@ fn state() -> TradeState {
#[test]
fn trade_frame_loads_and_key_regions_exist() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_ui(&s);
for name in [
@@ -107,6 +126,7 @@ fn trade_frame_loads_and_key_regions_exist() {
#[test]
fn trade_show_opens_and_both_columns_populate() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade(Some(state()));
@@ -172,6 +192,7 @@ fn trade_show_opens_and_both_columns_populate() {
#[test]
fn enchant_slot_shows_the_not_traded_note() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut st = state();
@@ -190,6 +211,7 @@ fn enchant_slot_shows_the_not_traded_note() {
#[test]
fn accept_update_drives_the_column_glows() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade(Some(state()));
@@ -230,6 +252,7 @@ fn accept_update_drives_the_column_glows() {
#[test]
fn closing_the_window_queues_the_cancel() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade(Some(state()));
@@ -251,6 +274,7 @@ fn closing_the_window_queues_the_cancel() {
#[test]
fn trade_button_click_queues_accept() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade(Some(state()));
@@ -267,6 +291,7 @@ fn trade_button_click_queues_accept() {
/// keystroke offers the running copper total through SetTradeMoney → the app's SET_TRADE_GOLD.
#[test]
fn player_money_input_reflects_then_offers() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade(Some(state())); // state().player.gold == 12345 (1g 23s 45c)
@@ -322,6 +347,7 @@ fn player_money_input_reflects_then_offers() {
/// player/recipient name-dispatch, without needing a cursor payload (the empty-cursor clear path).
#[test]
fn slot_click_routes_player_to_clear_and_recipient_to_inert() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_trade(Some(state())); // player slot 1 + recipient slot 1 are both filled
+3 -1
View File
@@ -20,10 +20,12 @@ mod common;
/// The tradeskill window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// CraftFrame.xml rides along (it loads right after TradeSkillFrame.xml in the app and shares
/// its guarded-global utilities) so a load error in EITHER window fails here.
const FILES: [&str; 8] = [
const FILES: [&str; 10] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
+136 -5
View File
@@ -542,11 +542,6 @@ fn load_creature_display_info_extra(chain: &mut Chain) -> Result<HashMap<u32, Np
mod tests {
use super::*;
/// The footprint accessor on the **real** build-5875 DBCs: a display wearing the HumanMale
/// body resolves the Base boot print (`FootprintTextures` id 1) at the authored 12×10 inches
/// → 1/3 × 5/18 yards (the client's ×1/36 cache conversion, byte-verified at `0x607a00`);
/// a display whose model authors `FootprintTextureID = −1` resolves `None`. Guards the
/// schema columns (a shifted field would misread every print) and the inches→yards space.
/// **The blood-row tier populations** over the shipped tables — the measurement behind 1850.
/// `CreatureModelData.BloodID = −1` (122 of 430 models) is a tier-2 *miss*, not a bloodless
/// marker, so those displays fall through to the reference's tier-3 records base and bleed
@@ -586,6 +581,142 @@ mod tests {
assert_eq!(tiers.iter().sum::<usize>(), cat.display.len());
}
/// **Tier 3 is ordinary fauna, not an exotic tail** — the content proof behind 1859, and the
/// reason the records-base fallback cannot mean "no blood".
///
/// Read the tier-3 population by its oddest members — elementals, skeletons, mecha-striders —
/// and the natural conclusion is that `BloodID = −1` marks a bloodless creature and the
/// fallback is a misread of the disassembly. The population says otherwise: it is *headed* by
/// Quilboar (42 displays), Mountain Giants (30), Crocolisks (27), Gnolls (25), Nagas (24) and
/// Trolls (17). A fallback that resolved to "no blood" would leave Razorfen, every gnoll camp
/// and every Stranglethorn troll bloodless — which is not the game anyone played. That is a
/// proof from shipped content, independent of any instruction decode, so the creatures are
/// named here: a future round that "simplifies" `level_key` back to two tiers fails saying
/// *which creature stopped bleeding*, not merely that a count moved.
#[test]
fn the_tier_three_fallback_bleeds_red_on_ordinary_creatures() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let blood = crate::load_blood_catalog(&mut chain).expect("blood tables");
// Each authors `BloodID = −1` on a single, uniquely-pathed model row, and none of their
// displays carries a `BloodLevel` override — so every one reaches the resolve by tier 3
// alone, with no other tier able to account for the result.
for path in [
r"Creature\Quillboar\QuillBoar.mdx",
r"Creature\Crocodile\Crocodile.mdx",
r"Creature\GnollMelee\GnollMelee.mdx",
r"Creature\MountainGiant\MountainGiant.mdx",
r"Creature\NagaFemale\Siren.mdx",
r"Creature\Troll\TrollMelee.mdx",
] {
let mut seen = 0usize;
for &display_id in cat.display.keys() {
let Some(m) = cat.model(display_id) else {
continue;
};
if !m.model_path.eq_ignore_ascii_case(path) {
continue;
}
seen += 1;
assert_eq!(
(m.blood_display, m.blood_model),
(0, -1),
"{path} display {display_id} is no longer a pure tier-3 case"
);
assert_eq!(
blood.level_key(m.blood_display, m.blood_model),
Some(1),
"{path} display {display_id} must fall through to the records base (RED)"
);
}
assert!(seen > 0, "{path} is missing from the shipped display table");
}
// …and the row it lands on really draws — red, both facings, both sizes.
for (front, large) in [(true, false), (true, true), (false, false), (false, true)] {
assert!(
blood.effect_id(1, 2, front, large).is_some(),
"the records-base row draws nothing (front {front}, large {large})"
);
}
}
/// **`BloodID = −1` is unfilled data, not a "bloodless" marker** — the evidence that closes
/// the question 1850 left open (1859).
///
/// Nine shipped models appear under **two** `CreatureModelData` rows for the same art and the
/// twins *disagree* about blood; in eight of the nine, one side of the disagreement is `−1`
/// (the ninth, FelBat, splits 1 vs 2). A Baby Murloc is a Baby
/// Murloc. If `−1` meant "this creature does not bleed", the same creature would bleed or not
/// depending on which of its two rows a display happened to name — so `−1` is an unspecified
/// value, and the reference's tier-3 records base is precisely the handler for one.
#[test]
fn the_minus_one_blood_id_is_unfilled_data() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let mut by_path: HashMap<String, Vec<i32>> = HashMap::new();
for m in cat.models.values() {
by_path
.entry(m.path.to_ascii_lowercase())
.or_default()
.push(m.blood);
}
let mut split: Vec<&str> = by_path
.iter()
.filter(|(_, ids)| ids.contains(&-1) && ids.iter().any(|&v| v > 0))
.map(|(p, _)| p.as_str())
.collect();
split.sort_unstable();
assert_eq!(
split.len(),
8,
"models whose duplicate rows disagree about blood, one of them −1: {split:?}"
);
assert!(
split.iter().any(|p| p.ends_with(r"murloc\babymurloc.mdx")),
"the Baby Murloc pair is the clearest case and must be among them: {split:?}"
);
// The same unfilled field, one level up: within a single creature family one model says
// −1 and its siblings name a colour. A "bloodless" reading would put bleeding quilboar
// warriors next to bloodless quilboar in the same Razorfen room.
let blood_of = |needle: &str| {
cat.models
.values()
.find(|m| m.path.to_ascii_lowercase().ends_with(needle))
.unwrap_or_else(|| panic!("{needle} is not in CreatureModelData"))
.blood
};
for (unspecified, sibling) in [
(
r"quillboar\quillboar.mdx",
r"quillboar\quillboarwarrior.mdx",
),
(r"gnollmelee\gnollmelee.mdx", r"gnollcaster\gnollcaster.mdx"),
(r"troll\trollmelee.mdx", r"troll\troll.mdx"),
(r"nagafemale\siren.mdx", r"nagamale\nagamale.mdx"),
] {
assert_eq!(
blood_of(unspecified),
-1,
"{unspecified} should be the unfilled one"
);
assert!(
blood_of(sibling) > 0,
"{sibling} should name a real colour, splitting its own family"
);
}
}
/// The footprint accessor on the **real** build-5875 DBCs: a display wearing the HumanMale
/// body resolves the Base boot print (`FootprintTextures` id 1) at the authored 12×10 inches
/// → 1/3 × 5/18 yards (the client's ×1/36 cache conversion, byte-verified at `0x607a00`);
/// a display whose model authors `FootprintTextureID = −1` resolves `None`. Guards the
/// schema columns (a shifted field would misread every print) and the inches→yards space.
#[test]
fn footprints_resolve_on_real_data() {
let data = crate::wow_data_or_skip!();
+10
View File
@@ -75,6 +75,16 @@ impl BloodCatalog {
/// not a bloodless marker. Reading `−1` as "bloodless" is what benilla did until 1850, and
/// it silently dropped the spurt on every one of those 595.
///
/// **The content proves tier 3 on its own** (1859), which matters because reading a
/// `setle/dec/and` correctly is a thing one can be wrong about, and this is not. The tier-3
/// population is headed by Quilboar, Mountain Giants, Crocolisks, Gnolls and Naga Sirens —
/// `Crocodile.mdx` being the *only* crocolisk model, so "no blood" here means no crocolisk in
/// the game ever bleeds. `−1` is **unspecified**, not *none*, and it splits families that
/// share a directory: `QuillBoar` is `−1` beside `QuillBoarWarrior` at `1`, `GnollMelee`
/// beside `GnollCaster`, `Troll\TrollMelee` beside `Troll\Troll`. It even splits a single
/// creature — eight models ship the same art under two `CreatureModelData` rows whose blood
/// ids disagree with one side `−1` (a Baby Murloc is both `1` and `−1`).
///
/// `None` only for an empty table — no shipped display is bloodless by this chain. A unit
/// still ends up with no blood at violence 0, where every row's column is `0`.
pub fn level_key(&self, display_blood: i32, model_blood: i32) -> Option<u32> {