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:
Generated
+14
-72
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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(¬ices) 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(())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user