Linux and Windows get their sound back, and emotes refuse while moving

- every build off macOS has been silent since the 09-02 snapshot (B356): the non-macOS device layer was a stub that opened nothing, and nothing had ever compiled the client for another target; that layer is now the same output stream over cpal — one seam, not two stacks: the ring, the render thread and the rebuild-on-device-change loop are one code on every platform, only the device file differs; the audio thread never allocates, a lost or changed default device is noticed at kira's own cadence, and realtime is `SCHED_FIFO` on unix and MMCSS "Pro Audio" on Windows, either refusable, the ring turning a refusal into a degradation; and the two instruments this bug says were missing exist — a Linux and Windows cross-compile under `clippy -D warnings`, and a live output test that opens the real device and asserts the ring never ran dry, because compiling is not sounding (1920)
- an emote that requires standing still now refuses on the move: `CheckEmoteEligible`'s `0x4000` arm writes an out-param that `DoEmote` turns into a red "You can't do that while moving!" and aborts; 20 emotes of 78 can raise the line, read off the shipped `Emotes.dbc`; SWIMMING is not in the mask, so a swimmer holding still emotes fine, and `IsSelfControlled` is TRUE for an ordinary player, so a feared or confused player emotes away happily (1904); and the cast path gains its equipped-item rung — the button greyed and pressing it still sent: the reference short-circuits to "fits" on four gates, not one, the miss being a zero `EquippedItemSubClassMask`, which means no item required at all, and the rung sits above the crowd-control validator, so a player both stunned and missing the weapon is told about the weapon; that ladder has six arms, not three — CHARMED, FLEEING and CONFUSED join (1925)
- the quest reward's money is the reference's own widget: the tail of the shared `QuestFrameItems_Update` drives `questState.."MoneyFrame"` through `MoneyFrame_Update`, and the two hand-built coin trios become one `MoneyFrameTemplate` instance each at the reference's anchors, which our coins already used; the progress panel stays on coins, being the required-money flow, not the reward one (1928); and the flight master's `!` joins the questgiver sweep, because the green `!` and the gold `!` are the SAME slot, `unit+0xb2c`, torn down once before both queries re-issue; the mouseover re-ask is refuted — the only taxi-status sender has one live caller — and the two queries are not symmetric: the questgiver's goes out on every sweep, the taxi one only when the unit's key moves or on a full sweep, as the wire shows at Dungar Longdrink: 68 questgiver status queries against 1 taxi-node one (1918)
- `GetSpellTabInfo` out of range answers `nil, nil, 0, 0` — the reference returns four values on every path that returns (`0x4b3ce0`), and ours answered a single nil copied from another binding's shape; it matters because stock `SpellBookFrame_OnLoad` calls it unconditionally and then does `id > (offset + numSpells)`, which a nil raises on and (0+0) makes true, hiding every button on the page — the behaviour to match; the argument raises the reference's usage string when absent, and the index truncates toward zero THEN decrements (1931); the perf probe gains a `WOW_PROBE_UNCAP=vsync` arm, because the uncapped present mode is a request, not a schedule — measured, it taxes the number it was meant to pin (11.80 `cpu_ms` uncapped against 14.65 under it), so the default stays uncapped; and the last three test temp paths are keyed on the pid, after concurrent runs raced on one fixed shared file

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:52:22 +07:00
co-authored by Claude Fable 5.1
parent 9f204d894b
commit b41acbf98a
40 changed files with 1745 additions and 426 deletions
Generated
+71 -4
View File
@@ -121,6 +121,18 @@ 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"
@@ -508,6 +520,7 @@ dependencies = [
"bytemuck",
"coreaudio-rs 0.13.0",
"cosmic-text",
"cpal 0.17.1",
"crossbeam-channel",
"fontdb",
"half",
@@ -862,7 +875,7 @@ dependencies = [
"bevy_reflect",
"bevy_transform",
"coreaudio-sys",
"cpal",
"cpal 0.15.3",
"rodio",
"tracing",
]
@@ -2641,14 +2654,14 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"mach2 0.4.3",
"ndk 0.8.0",
"ndk-context",
"oboe",
@@ -2658,6 +2671,36 @@ 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"
@@ -4247,6 +4290,15 @@ 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"
@@ -4672,6 +4724,16 @@ 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"
@@ -4706,6 +4768,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4737,7 +4800,9 @@ 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",
]
@@ -4804,6 +4869,8 @@ 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",
]
@@ -5639,7 +5706,7 @@ version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
dependencies = [
"cpal",
"cpal 0.15.3",
"lewton",
]
+17 -3
View File
@@ -99,9 +99,11 @@ cosmic-text = "0.16"
# `fontdb` in the graph.
fontdb = "0.23"
# 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's own `cpal` backend is not enabled on any target. On macOS that took cpal out of the graph
# entirely: it could neither report a lost default device nor rebuild on one, and it discarded the
# IO-cycle timestamp the deadline meters need. Off macOS cpal is back (1920) but as OUR device
# layer under the same backend, not as kira's — the ring, the render thread and the meters are the
# same code on every platform.
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
@@ -167,3 +169,15 @@ objc2-app-kit = { version = "0.2", features = [
"NSEvent",
] }
objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeometry"] }
# Everywhere else: the same device layer over cpal (decision 1920, `sound::output::cpal`). 1857
# owns the mix-ahead ring, the render thread and the meters on every platform; only the last
# hop — find the default device, open a stream on it, hand back one buffer per cycle — is
# platform code, and off macOS that hop is ALSA/PipeWire, WASAPI or the host's own API. cpal is
# the layer that already speaks all of them; on macOS it is still not in the graph, because
# there its defects (a no-op error callback on the default device, a pinned `DefaultOutput`
# unit, the discarded IO-cycle timestamp) are exactly what 1857 went direct to escape. On an
# unsupported target cpal compiles to its Null host, which reports no device — the same silent
# posture the old `stub.rs` had, without a second file to rot.
[target.'cfg(not(target_os = "macos"))'.dependencies]
cpal = "0.17"
+23 -10
View File
@@ -206,7 +206,7 @@
local t = { "QuestDescription", "QuestObjectiveText", "QuestDetailRewardTitleText",
"QuestDetailItemChooseText", "QuestDetailItemReceiveText" }
for i = 1, MAX_NUM_ITEMS do table.insert(t, "QuestDetailItem" .. i) end
for i = 1, 3 do table.insert(t, "QuestDetailMoneyCoin" .. i) end
table.insert(t, "QuestDetailMoneyFrame")
return t
end)(),
["QuestProgressScrollFrame"] = (function()
@@ -220,7 +220,7 @@
local t = { "QuestRewardText", "QuestRewardRewardTitleText",
"QuestRewardItemChooseText", "QuestRewardItemReceiveText" }
for i = 1, MAX_NUM_ITEMS do table.insert(t, "QuestRewardItem" .. i) end
for i = 1, 3 do table.insert(t, "QuestRewardMoneyCoin" .. i) end
table.insert(t, "QuestRewardMoneyFrame")
return t
end)(),
}
@@ -358,6 +358,16 @@
HideUIPanel(getglobal("QuestFrame"))
end
-- The reference's own `QuestMoneyFrame_OnLoad` (QuestFrame.lua:306-309), which lives in
-- THIS file in the reference too — so it is home, not a squatter. Its whole body is these
-- two calls. Needed because 1928 put the two reward panels on `MoneyFrameTemplate`, whose
-- instances call it from their OnLoad exactly as the reference's do. Both callees are our
-- `MoneyFrame.xml`'s, which the manifest loads at entry 141 — far above this file.
function QuestMoneyFrame_OnLoad()
MoneyFrame_OnLoad()
MoneyFrame_SetType("STATIC")
end
-- ── The shared reward grid — `QuestFrameItems_Update(questState)`, the REFERENCE'S OWN
-- NAME and the reference's own algorithm (QuestFrame.lua:311-522), ported line-for-line.
--
@@ -454,10 +464,17 @@
end
end
-- The reference's own tail (QuestFrame.lua:350-356), on the reference's own widget.
-- `QuestFrame_SetAsLastShown` is deliberately NOT called: it anchors a SPACER FRAME
-- that this client does not have — our panels size their scroll child from
-- `BENILLA_QUEST_PANEL_WIDGETS` instead (see 1928). The money half converges here; the
-- sizing model stays ours until this window migrates.
local moneyFrame = getglobal(questState .. "MoneyFrame")
if money and money > 0 then
BenillaMoney_Set(questState .. "Money", money, true)
moneyFrame:Show()
MoneyFrame_Update(questState .. "MoneyFrame", money)
else
BenillaMoney_Clear(questState .. "Money")
moneyFrame:Hide()
end
end
@@ -1154,9 +1171,7 @@
<Button name="QuestDetailItem9" inherits="QuestItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestDetailItem10" inherits="QuestItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Frame name="QuestDetailMoneyCoin1" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestDetailItemReceiveText" relativePoint="RIGHT"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors></Frame>
<Frame name="QuestDetailMoneyCoin2" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestDetailMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors></Frame>
<Frame name="QuestDetailMoneyCoin3" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestDetailMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors></Frame>
<Frame name="QuestDetailMoneyFrame" inherits="MoneyFrameTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestDetailItemReceiveText" relativePoint="RIGHT"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors><Scripts><OnLoad>QuestMoneyFrame_OnLoad()</OnLoad></Scripts></Frame>
</Frames>
</Frame>
</Frames>
@@ -1292,9 +1307,7 @@
<Button name="QuestRewardItem9" inherits="QuestRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestRewardItem10" inherits="QuestRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Frame name="QuestRewardMoneyCoin1" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestRewardItemReceiveText" relativePoint="RIGHT"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors></Frame>
<Frame name="QuestRewardMoneyCoin2" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestRewardMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors></Frame>
<Frame name="QuestRewardMoneyCoin3" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestRewardMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors></Frame>
<Frame name="QuestRewardMoneyFrame" inherits="MoneyFrameTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestRewardItemReceiveText" relativePoint="RIGHT"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors><Scripts><OnLoad>QuestMoneyFrame_OnLoad()</OnLoad></Scripts></Frame>
<!-- The choice-selection glow (moved onto the clicked choice
row; ref QuestRewardItemHighlight, QuestFrame.xml:254-278
+12
View File
@@ -553,9 +553,21 @@ fn resize_request() -> Option<(u32, u32)> {
/// doesn't uncap" was the *power state* withholding the grant, not the mode — no present mode
/// escapes that; `cpu_ms` on the probe line is the rail-proof metric. `WOW_PROBE_UNCAP=immediate`
/// re-runs the losing arm when macOS/wgpu move.
///
/// `WOW_PROBE_UNCAP=vsync` does not uncap at all: the leg keeps the player's present mode and
/// rails at the display's rate on purpose. It was built because the WindowServer's grant is not
/// ours to schedule — one sitting on the M2 Air read 123.5, 60.0, 60.0 and 87.5 fps across four
/// otherwise identical uncapped legs — and 1442's "railed-60.0 pairs only" rule discards every
/// pair the grant split. **Measured the same day, it is not a neutral instrument:** two
/// interleaved rounds at the Goldshire pin, no crowd, read `cpu_ms` 11.80 / 11.55 uncapped
/// (both railed 60.0 by the WindowServer anyway) against 14.65 / 12.43 under this arm — the
/// vsync wait costs the process CPU, and unevenly. So the default stays uncapped and a pair is
/// still accepted only when both legs happened to rail; this arm is for a sitting whose legs
/// refuse to rail at all, with the tax read against a same-arm baseline.
pub(crate) fn probe_uncap_mode() -> bevy::window::PresentMode {
match std::env::var("WOW_PROBE_UNCAP").as_deref() {
Ok("immediate") => bevy::window::PresentMode::Immediate,
Ok("vsync") => bevy::window::PresentMode::AutoVsync,
_ => bevy::window::PresentMode::AutoNoVsync,
}
}
@@ -249,7 +249,7 @@ pub(super) type MatQuint = (
/// The full character material set [`build_char_skin_materials`] returns: `(body, hair, object,
/// skin_extra)`, the body + skin-extra each a (single-sided, two-sided) pair. Each per-slot
/// [`MatQuint`] is `None` for an absent row (a bald style, a non-fur race) or missing tables. Named
/// so the create-preview builder ([`super::create_preview`]) can select the steady variant per part.
/// so the create-preview builder (`super::create_preview`) can select the steady variant per part.
pub(super) type CharSkinMaterials = (
Option<(MatQuint, MatQuint)>,
Option<MatQuint>,
+25
View File
@@ -490,6 +490,14 @@ impl Items {
})
}
/// [`Self::template`]'s **read-only** twin: the record for `entry` only if it is already
/// cached, and never an ask. For callers holding `&Items` — the cast ladder's equipped-item
/// rung runs inside a `&Items` borrow, and by the time a button is pressed the greying feed
/// that shares its search has had the template for many frames.
pub(crate) fn template_cached(&self, entry: u32) -> Option<&ItemInfo> {
self.templates.get(&entry)?.as_ref()
}
/// Record a template answer (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`); `None` = unknown entry.
pub(crate) fn insert_template(&mut self, entry: u32, info: Option<ItemInfo>) {
self.pending.remove(&entry);
@@ -614,6 +622,23 @@ pub(crate) fn disarmed_equipment_slot(
crate::creature_anim::disarmed_hand(main, off).map(|hand| EQUIPMENT_SLOT_MAINHAND + hand as u8)
}
/// [`disarmed_equipment_slot`]'s read-only twin — same ladder, no ask (decision 1925).
pub(crate) fn disarmed_equipment_slot_cached(store: &ObjectStore, items: &Items) -> Option<u8> {
if store.0.unit_flags() & crate::creature_anim::UNIT_FLAG_DISARMED == 0 {
return None;
}
let class_of = |slot: u8| -> Option<u8> {
let guid = store.0.player_inv_slot(slot).filter(|&g| g != 0)?;
let entry = items.object(guid).and_then(|o| o.object_entry())?;
Some(items.template_cached(entry)?.class as u8)
};
crate::creature_anim::disarmed_hand(
class_of(EQUIPMENT_SLOT_MAINHAND),
class_of(EQUIPMENT_SLOT_OFFHAND),
)
.map(|hand| EQUIPMENT_SLOT_MAINHAND + hand as u8)
}
/// A minimal, VALID item template named `name` — the shared test seam for every module that
/// needs a landed template (the sentinels that matter are `allowable_*` = −1 and `stackable` = 1).
#[cfg(test)]
+2
View File
@@ -310,6 +310,8 @@ pub(crate) fn screenshots_dir() -> Option<PathBuf> {
/// this instrument was built for — the director's, and the long probe rounds — keep it. The
/// alternative, an ungated second accessor, would put "which paths are exempt from hermetic?" back
/// into someone's head, which is what the single rule exists to prevent.
// Same one reader as `sound::output::device_open`: the macOS-only stall watchdog.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn diagnostics_dir() -> Option<PathBuf> {
home().map(|h| h.join("Diagnostics"))
}
+1 -1
View File
@@ -2224,7 +2224,7 @@ pub(crate) struct LoginQueuedMessage {
/// The session ended (socket death, logout's teardown edge) — bridged from the Net drain's
/// `Disconnected` arm. [`crate::login`]'s policy reads it as "the IO thread is back at its
/// pre-logon park"; whether anything re-authenticates is [`Self::ends_the_session`]'s answer.
/// pre-logon park"; whether anything re-authenticates is `Self::ends_the_session`'s answer.
#[derive(Message, Clone)]
pub(crate) struct DisconnectedMessage {
pub(crate) reason: String,
+1 -1
View File
@@ -168,7 +168,7 @@ impl RelayChain {
/// The scheduling lead this move got — `fire − arrival`, i.e. how long the dead-reckon has to
/// cover before the pose lands. Negative means the move was already due and applies at arrival.
/// Trace/diagnostic only ([`super::remote::trace_schedule`]).
/// Trace/diagnostic only (`super::remote::trace_schedule`).
pub(crate) fn lead_ms(&self, now_ms: f64) -> f64 {
self.last_fire_ms - now_ms
}
+21
View File
@@ -164,6 +164,27 @@ pub(crate) use view_subject::ViewSubject;
/// `0x600dd7`.
pub(crate) const UNIT_FLAG_STUNNED: u32 = 0x0004_0000;
/// **`IsSelfControlled`** — the reference's `0x5fa550` (`5fa566 a9 04 00 c0 00 test eax, 0xc00004`):
/// is this unit acting under its *own* control? False while `DISABLE_MOVE` (`0x4`), `CONFUSED`
/// (`0x400000`) or `FLEEING` (`0x800000`) — and note what is **absent**: `UNIT_FLAG_STUNNED` is
/// not in the mask (it has its own, separate gate), and neither is the taxi bit. wow-re
/// `object-layer/scratch/unit-flags-movement-gates.md` §4.
///
/// Its consumers in the reference are the movement/collision layer and `DoEmote` — and the
/// polarity is the trap: it returns **1 for an ordinary player**, so a gate written on it fires
/// in the *normal* case and is suppressed while feared, not the other way round.
///
/// **One leg is deliberately unmodelled.** `POSSESSED` (`0x1000000`) does not refuse here: the
/// reference redirects at `0x5fa582` to the charmer's GUID and **recurses** into itself, so the
/// answer is the charmer's. Reproducing that needs the charmer's descriptor, and the only
/// consumer that would notice is whether a red toast appears while mind-controlled. Named rather
/// than invented (decision 1904).
pub(crate) fn self_controlled(unit_flags: u32) -> bool {
/// `DISABLE_MOVE | CONFUSED | FLEEING` — the reference's literal `0xc00004`.
const NOT_SELF_CONTROLLED: u32 = 0x0000_0004 | 0x0040_0000 | 0x0080_0000;
unit_flags & NOT_SELF_CONTROLLED == 0
}
/// `UNIT_FLAG_IN_COMBAT` — the same `UNIT_FIELD_FLAGS` word, **bit 19** (vmangos
/// `UnitDefines.h`; the client reads it as `shr reg,0x13; test rl,1`).
///
+214 -11
View File
@@ -1,6 +1,13 @@
//! The **query** half of the overhead questgiver markers: when to ask the server what an NPC's
//! `!`/`?` should be — and, for a GameObject, when to ask a question whose answer is thrown away.
//! The rendering half is [`super`].
//! The **query** half of the overhead markers: when to ask the server what an NPC's `!`/`?` should
//! be — and, for a GameObject, when to ask a question whose answer is thrown away. The rendering
//! half is [`super`].
//!
//! **Both markers, not just the questgiver one** (decision 1918). The gold `!` and the flight
//! master's green `!` are the same `unit+0xb2c`: `0x607480` installs whichever arrives into that
//! one slot, `0x6073f0` zeroes it (along with `+0xcb8`), and `0x607380` — the per-unit path — tears
//! it down once and then re-issues *both* queries, `0x182` on `UNIT_NPC_FLAGS` bit 1 and `0x1aa` on
//! bit 3. One slot cannot have two lifetimes, so one system owns both asks; `ui_taxi` keeps the
//! resulting fact ([`crate::ui_taxi::FlightMasterStatus`]) and nothing else.
//!
//! **A GameObject is queried and never rendered** (decision 1872, wow-re `questgiver-marker.md`
//! §W14). The reference sends `CMSG_QUESTGIVER_STATUS_QUERY` for a quest-flagged GameObject from
@@ -29,6 +36,9 @@ use crate::ui_quest::QuestGiver;
/// `UNIT_NPC_FLAGS` questgiver service bit (vanilla; `update_object.rs`'s accessor doc).
const NPC_FLAG_QUESTGIVER: u32 = 0x2;
/// `UNIT_NPC_FLAGS` flightmaster service bit — the other half of `0x607380`'s re-gate
/// (`0x6073de shr eax,3; test al,1`), and the gate on the only live `0x1aa` sender there is.
const NPC_FLAG_FLIGHTMASTER: u32 = 0x8;
/// `GAMEOBJECT_FLAGS` bit **2** (mask `0x4`) — the whole of the sweep's GameObject gate, and the
/// one place a GameObject GUID can reach this opcode at all.
///
@@ -125,12 +135,22 @@ const GO_FLAG_INTERACT_COND: u32 = 0x4;
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // a Bevy system: one param per resource
pub(super) fn query_statuses(
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
objects: Query<(&crate::net::Guid, &NetEntity, &ObjectStore), Without<SelfPlayer>>,
objects: Query<
(
Entity,
&crate::net::Guid,
&NetEntity,
&ObjectStore,
Option<&crate::ui_taxi::FlightMasterStatus>,
),
Without<SelfPlayer>,
>,
index: Res<GuidIndex>,
factions: Option<Res<Factions>>,
reputations: Res<crate::net::Reputations>,
mut quest: ResMut<QuestGiver>,
commands: Res<NetCommands>,
mut ecs: Commands,
mut state: Local<QueryState>,
) {
let Some(store) = self_q.iter().next() else {
@@ -175,7 +195,18 @@ pub(super) fn query_statuses(
// the second is a `0x607380` and only a `0x607380` tears the marker down.
state.asked.retain(|guid, _| index.0.contains_key(guid));
quest.retain_statuses(|npc| index.0.contains_key(&npc));
for (guid, net, obj) in &objects {
for (entity, guid, net, obj, fm) in &objects {
// `0x6073f0` zeroes `+0xb2c` (the marker instance) and `+0xcb8` (the questgiver status)
// **together**, and the flight master's green `!` lives in that same `+0xb2c` — so every
// teardown below takes both. Only removed when there is something to remove: a `Commands`
// write per unit per frame would be a queue of no-ops.
let tear_down = |quest: &mut QuestGiver, ecs: &mut Commands| {
quest.clear_status(guid.0);
if fm.is_some() {
ecs.entity(entity)
.remove::<crate::ui_taxi::FlightMasterStatus>();
}
};
match net.kind {
// `0x5eb0a0` @ `0x5eb0d3`: `cmp eax,9; je` — typemask EXACTLY `OBJECT|UNIT`, a plain
// creature. A player is `0x19` and falls out of the sweep here, before any flag test.
@@ -197,7 +228,7 @@ pub(super) fn query_statuses(
// hostile creature whether or not it is a questgiver. Dropping the ask key too
// means the marker comes back through a `0x607380` when the reaction recovers.
state.asked.remove(&guid.0);
quest.clear_status(guid.0);
tear_down(&mut quest, &mut ecs);
continue;
}
let key = unit_ask_key(&obj.0);
@@ -214,16 +245,30 @@ pub(super) fn query_statuses(
// "disable npcflags"), and its cached AVAILABLE status — with its `!` — stayed
// frozen over the NPC for the whole escort. The flag is half of [`unit_ask_key`],
// so the drop moves the key, and the key moving is a `0x607380`.
if key_moved || full {
quest.clear_status(guid.0);
let per_unit = key_moved || full;
if per_unit {
tear_down(&mut quest, &mut ecs);
}
// …and re-gate: the questgiver bit, on this unit's own key change and on every
// sweep. (Reaction is already `> 1` — the arm above returned otherwise.)
if (key_moved || swept) && obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER != 0 {
// …and re-gate. Reaction is already `> 1` — the arm above returned otherwise —
// so what is left is the two service bits, and **they are not symmetric**:
//
// - `0x182` (questgiver) is issued by `0x607380` @`0x6073cd` on bit `0x2` **and**
// inline by the light sweep's own callback (`0x5eb159`), so every sweep re-asks;
// - `0x1aa` (taxi node status) has exactly ONE live sender site image-wide —
// `0x607380` @`0x6073e8` — so it is re-asked on this unit's own key moving and
// on a full sweep, and **never** on a light one. `0x5eb0a0` does not test bit
// `0x8` at all.
let flags = obj.0.unit_npc_flags();
if (per_unit || swept) && flags & NPC_FLAG_QUESTGIVER != 0 {
let _ = commands
.0
.send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 });
}
if per_unit && flags & NPC_FLAG_FLIGHTMASTER != 0 {
let _ = commands
.0
.send(ClientCommand::TaxiNodeStatusQuery { guid: guid.0 });
}
}
// `0x5eb0da shr ecx,5; test cl,1` — typemask bit 5, `TYPEMASK_GAMEOBJECT`. Sweep-only,
// no asked-set, no teardown: see this function's doc and §W14.8.
@@ -785,6 +830,164 @@ mod tests {
);
}
/// **The flight master's green `!` shares one slot with the gold one, so it shares its
/// lifetime** (decision 1918, wow-re §W16). Three things, and the middle one is the asymmetry
/// a re-implementer gets wrong:
///
/// - `CMSG_TAXINODE_STATUS_QUERY` has **one** live sender site image-wide, `0x607380`
/// @`0x6073e8` — so it goes out on this unit's own key moving and on a full sweep, and
/// **never** on a light one. There is no mouseover trigger; the function that claim rested on
/// (`0x5eb220`) is the callback of a seeder nothing calls.
/// - the questgiver query is the opposite: the light sweep's callback sends it inline
/// (`0x5eb159`), so every sweep re-asks.
/// - and `0x6073f0` zeroes `+0xb2c` and `+0xcb8` together, so whatever tears one marker down
/// takes the other with it.
#[test]
fn a_flight_master_is_asked_by_the_per_unit_path_only_and_loses_its_green_with_the_gold() {
use crate::net::{Guid, NetEntity};
use crate::ui_taxi::FlightMasterStatus;
use benilla_protocol::{EntityKind, ObjectFields};
const FM: u64 = 0x9001; // flightmaster only
const GIVER: u64 = 0x9002; // questgiver only — the asymmetry's control
const VENDOR: u64 = 0x9003; // neither — never asked at all
const FIELD_LEVEL: u16 = 34;
const FIELD_HEALTH: u16 = 22;
const FIELD_NPC_FLAGS: u16 = 147;
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.init_resource::<crate::net::Reputations>()
.add_systems(Update, query_statuses);
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(1),
ObjectStore(ObjectFields::from_pairs(&[
(FIELD_LEVEL, 5),
(FIELD_HEALTH, 100),
])),
))
.id();
let spawn = |app: &mut App, guid: u64, flags: u32| {
let e = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Guid(guid),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, flags)])),
))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(guid, e);
e
};
let fm = spawn(&mut app, FM, NPC_FLAG_FLIGHTMASTER);
spawn(&mut app, GIVER, NPC_FLAG_QUESTGIVER);
spawn(&mut app, VENDOR, 0x4);
let drain = |rx: &crossbeam_channel::Receiver<ClientCommand>| -> (Vec<u64>, Vec<u64>) {
let (mut taxi, mut giver) = (Vec::new(), Vec::new());
for c in rx.try_iter() {
match c {
ClientCommand::TaxiNodeStatusQuery { guid } => taxi.push(guid),
ClientCommand::QuestgiverStatusQuery { npc } => giver.push(npc),
_ => {}
}
}
(taxi, giver)
};
// On sight: the create/init hook IS a `0x607380`, so both services are asked, once each.
app.update();
let (taxi, giver) = drain(&rx);
assert_eq!(taxi, vec![FM], "the flight master is asked on sight");
assert_eq!(
giver,
vec![GIVER],
"and so is the questgiver — the vendor never"
);
app.update();
assert_eq!(drain(&rx), (vec![], vec![]), "then quiet");
// A LIGHT sweep: the questgiver is re-asked inline by the callback; the flight master is
// not, because `0x5eb0a0` does not test bit 0x8 at all.
app.world_mut().resource_mut::<QuestGiver>().bump_reask();
app.update();
let (taxi, giver) = drain(&rx);
assert_eq!(giver, vec![GIVER], "a light sweep re-asks the questgiver");
assert_eq!(
taxi,
vec![] as Vec<u64>,
"…and never the flight master: 0x1aa has one sender site and it is not in the sweep"
);
// The green marker is up, and a FULL sweep (a revive) takes it down and re-asks.
app.world_mut()
.entity_mut(fm)
.insert(FlightMasterStatus { known: false });
let set_self = |app: &mut App, hp: u32| {
*app.world_mut()
.entity_mut(me)
.get_mut::<ObjectStore>()
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[
(FIELD_LEVEL, 5),
(FIELD_HEALTH, hp),
]));
};
set_self(&mut app, 0); // death — a LIGHT sweep
app.update();
assert_eq!(
drain(&rx).0,
vec![] as Vec<u64>,
"death does not re-ask taxi"
);
assert!(
app.world().entity(fm).get::<FlightMasterStatus>().is_some(),
"…and a light sweep leaves the green marker up"
);
set_self(&mut app, 100); // revive — a FULL sweep
app.update();
assert_eq!(drain(&rx).0, vec![FM], "a revive re-asks the flight master");
assert!(
app.world().entity(fm).get::<FlightMasterStatus>().is_none(),
"…after tearing its green marker down first (0x607384, the shared 0x6073f0)"
);
// And its own key moving is a `0x607380` too: the flightmaster bit going off takes the
// marker with it, and does not re-ask.
app.world_mut()
.entity_mut(fm)
.insert(FlightMasterStatus { known: false });
*app.world_mut()
.entity_mut(fm)
.get_mut::<ObjectStore>()
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0)]));
app.update();
assert_eq!(
drain(&rx).0,
vec![] as Vec<u64>,
"no longer a flight master"
);
assert!(
app.world().entity(fm).get::<FlightMasterStatus>().is_none(),
"and its green marker goes with the bit"
);
}
/// **A GameObject is asked about on a SWEEP, and only on a sweep** (decision 1872, wow-re
/// `questgiver-marker.md` §W14.6/§W14.8). Three things are being pinned here, and the third is
/// the one a client "improves" without noticing:
@@ -264,15 +264,31 @@ pub(super) struct Cycle<'a> {
/// 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,
/// The IO buffer the HAL actually granted, frames.
buffer_frames: u32,
}
impl Stream {
/// The cycle size the device is actually running, frames — read back after the open,
/// because the HAL may quietly hand back a different size than asked (Chromium documents
/// the silent clamp).
pub(super) fn buffer_frames(&self) -> u32 {
self.buffer_frames
}
/// 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>
///
/// `_notices` is what a stream raises on its own where the platform has no listener API
/// (`cpal.rs` wires it to cpal's error callback); here every notice comes from
/// [`Listeners`], so the stream needs none of it.
pub(super) fn open<F>(
device: &Device,
buffer_frames: u32,
_notices: &Arc<Notices>,
mut on_cycle: F,
) -> Result<Self>
where
F: FnMut(Cycle<'_>) + Send + 'static,
{
@@ -336,8 +352,6 @@ impl Stream {
})
.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,
@@ -419,7 +433,8 @@ pub(super) struct Listeners {
}
impl Listeners {
pub(super) fn arm(device: AudioObjectID, notices: Arc<Notices>) -> Self {
pub(super) fn arm(device: &Device, notices: Arc<Notices>) -> Self {
let device = device.id;
let client = Arc::as_ptr(&notices) as *mut c_void;
let wanted = [
(
@@ -522,9 +537,9 @@ pub(super) struct Workgroup(OsWorkgroup);
unsafe impl Send for Workgroup {}
impl Workgroup {
pub(super) fn of_device(device: AudioObjectID) -> Option<Self> {
pub(super) fn of_device(device: &Device) -> Option<Self> {
let wg: OsWorkgroup = get(
device,
device.id,
kAudioDevicePropertyIOThreadOSWorkgroup,
kAudioObjectPropertyScopeGlobal,
)
@@ -571,10 +586,15 @@ impl Drop for Joined {
}
}
/// The render thread's realtime standing. A time-constraint policy lasts as long as the thread
/// does and needs no unwinding, so this carries nothing; it exists because the Windows half of
/// the same surface (`cpal.rs`) has an MMCSS registration to hand back.
pub(super) struct Realtime;
/// 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<()> {
pub(super) fn set_realtime(period_ns: u64) -> Result<Realtime> {
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
@@ -597,5 +617,5 @@ pub(super) fn set_realtime(period_ns: u64) -> Result<()> {
if rc != 0 {
return Err(anyhow!("thread_policy_set(TIME_CONSTRAINT) failed ({rc})"));
}
Ok(())
Ok(Realtime)
}
+567
View File
@@ -0,0 +1,567 @@
//! The non-macOS half of [`super`] — the same surface as `coreaudio.rs`, over cpal
//! (decision 1920: Linux and Windows had been silent since the 09-02 sync, bug B356).
//!
//! [`super`] owns everything that decides *how* the mix reaches the speaker: the mix-ahead
//! ring, the render thread, the meters, the rebuild-on-device-change loop. That is platform
//! code nowhere. What is platform code is only the last hop — find the default output device,
//! open a stream on it, and hand our IO callback one buffer per cycle — and off macOS that hop
//! is ALSA or PipeWire on Linux, WASAPI on Windows, and something else again on the BSDs. cpal
//! is the layer that already speaks all of them, so this file is cpal wearing the shape
//! `coreaudio.rs` wears: [`Device`], [`Stream`], [`Cycle`], [`Notices`], [`Listeners`],
//! [`set_realtime`], [`now_ns`].
//!
//! ## The three places cpal does not hand us what CoreAudio does
//!
//! - **Cycle timestamps.** CoreAudio stamps every IO cycle with the host time its first frame
//! reaches the DAC; the *lead* meter is that minus now. cpal gives the same quantity split in
//! two (`OutputStreamTimestamp`'s `callback` and `playback`), so the lead is their difference
//! and [`Cycle::output_time_ns`] is `now + (playback − callback)` — the same number the meter
//! is about, reconstructed on our own clock.
//! - **Device notices.** CoreAudio raises a property listener when the default output changes,
//! when a device dies, when its rate changes. cpal has no notification API at all, so
//! [`Listeners`] is a half-second poll of the default device's identity and rate (the same
//! mechanism kira's own cpal backend uses off macOS) plus cpal's stream error callback, which
//! *is* delivered on these hosts — unlike on macOS, where cpal 0.17 wires a no-op for the
//! default device. `kAudioDeviceProcessorOverload` has no analogue: the overload meter reads
//! zero here, and the audible failure is counted where it always was, as a ring underrun.
//! - **Realtime scheduling.** No workgroups; [`Workgroup`] is inert. [`set_realtime`] is
//! `SCHED_FIFO` on unix and MMCSS "Pro Audio" on Windows — see its own docs for what each
//! costs when the OS refuses.
//!
//! Everything else — including the fact that an unsupported target simply reports no device and
//! the client runs silent — falls out of cpal, whose Null host is what it compiles to there.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};
/// The channel count we render. kira mixes stereo; [`spread`] puts it over whatever the device
/// actually takes.
pub(super) const CHANNELS: u32 = 2;
/// How often [`Listeners`] re-reads the default output device. Half a second is kira's own
/// cadence for this poll off macOS, and it is the granularity at which "I plugged headphones
/// in" should become audible.
const WATCH_EVERY: Duration = Duration::from_millis(500);
/// The most frames one [`Cycle`] carries. A host that hands us a bigger buffer than this gets
/// it served in several cycles rather than an allocation on the audio thread ([`Stream::open`]
/// sizes the scratch once, at open). Generous on purpose: 8192 frames is 170 ms at 48 kHz, well
/// past any buffer a host has been seen to ask for, and 64 KB of scratch is nothing.
const MAX_CYCLE_FRAMES: usize = 8192;
/// Nanoseconds on a monotonic clock. Not the device's clock — cpal's `StreamInstant` is
/// host-defined and not comparable across hosts, so the meters ride [`Instant`] and the one
/// device-timeline quantity we need (the lead) is reconstructed from a *difference* of stream
/// instants, which is well-defined everywhere.
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
}
// ---------------------------------------------------------------------------------------------
// Device
/// One output device as cpal reports it at open time.
#[derive(Clone)]
pub(super) struct Device {
pub name: String,
/// The rate the device's default config runs at — what we render at (nothing below us
/// resamples).
pub sample_rate: u32,
/// The buffer sizes the host says it accepts, frames.
pub buffer_range: (u32, u32),
/// cpal exposes no device latency or safety offset; the report prints 0 rather than a guess.
pub latency_frames: u32,
pub safety_frames: u32,
/// The cpal handle and the config we will open with. Private: [`super`] only ever reads the
/// descriptive fields above.
device: cpal::Device,
config: cpal::SupportedStreamConfig,
/// The host's stable id for this device, when it has one. This — not [`Self::name`] — is
/// what [`Listeners`] compares: a display name is neither unique nor fixed (two identical
/// headsets, a renamed endpoint), and cpal 0.17 deprecates `name()` for exactly that.
id: Option<cpal::DeviceId>,
}
impl std::fmt::Debug for Device {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Device")
.field("name", &self.name)
.field("sample_rate", &self.sample_rate)
.field("buffer_range", &self.buffer_range)
.field("channels", &self.config.channels())
.field("sample_format", &self.config.sample_format())
.finish()
}
}
/// The host's current default output device, fully described. `Err` when there is none (a
/// headless machine, no sound server running, an unsupported target) — the caller runs silent.
pub(super) fn default_output() -> Result<Device> {
// Prime the monotonic epoch here, on the main thread: the first reader is otherwise the
// audio callback, and a clock that starts on the realtime path is one more thing to reason
// about for no gain.
now_ns();
let host = cpal::default_host();
let device = host
.default_output_device()
.context("no default output device")?;
describe(device)
}
fn describe(device: cpal::Device) -> Result<Device> {
let name = device
.description()
.map(|d| d.name().to_string())
.unwrap_or_else(|_| "unnamed output device".to_string());
// The default config is the one config the host guarantees it can open, and cpal's own
// heuristic already prefers f32 and stereo within it. Negotiating our own would only add a
// way to be refused.
let config = device
.default_output_config()
.with_context(|| format!("default output config for {name}"))?;
let sample_rate = config.sample_rate();
if !(8000..=384_000).contains(&sample_rate) {
bail!("device {name} reports an absurd sample rate {sample_rate}");
}
if config.channels() == 0 {
bail!("device {name} reports zero output channels");
}
let buffer_range = match *config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (min.max(1), max.max(1)),
// "Unknown" means the host will not take a size from us at all; `Stream::open` reads
// that back off the config and asks for the host's default instead.
cpal::SupportedBufferSize::Unknown => (1, u32::MAX),
};
Ok(Device {
name,
sample_rate,
buffer_range,
latency_frames: 0,
safety_frames: 0,
id: device.id().ok(),
device,
config,
})
}
// ---------------------------------------------------------------------------------------------
// Stream
/// What the IO callback sees each cycle: the interleaved **stereo** buffer to fill and the time
/// at which its first frame reaches the DAC.
pub(super) struct Cycle<'a> {
pub buffer: &'a mut [f32],
pub frames: usize,
/// When this cycle's first frame is due, in nanoseconds on the [`now_ns`] clock —
/// reconstructed as `now + (playback − callback)` from cpal's pair of stream instants.
/// Zero if the host gave no usable timestamp.
pub output_time_ns: u64,
}
/// An open, running output stream on one device. Dropping it stops the stream and drops the
/// callback (and with it whatever the closure owned) — cpal joins its own audio thread first.
pub(super) struct Stream {
_stream: cpal::Stream,
observed_frames: Arc<AtomicU32>,
}
impl Stream {
/// The cycle size the device is actually running, frames.
///
/// cpal cannot tell us what the host granted, and on WASAPI's shared mode it varies from
/// wake to wake, so the honest number is the one the last callback carried rather than the
/// one we asked for. Seeded with the request so the first report is never zero.
pub(super) fn buffer_frames(&self) -> u32 {
self.observed_frames.load(Ordering::Relaxed)
}
/// Open `device` at its default config with a buffer of `buffer_frames` (clamped to what the
/// host says it accepts) and start it. `on_cycle` runs on cpal's audio thread — it must
/// never block, allocate, or log.
///
/// `notices` is wired to cpal's stream error callback: on these hosts it is delivered (the
/// no-op 1857 found was macOS-and-default-device only), and it is the promptest signal that
/// the device is gone. The poll in [`Listeners`] is the backstop.
pub(super) fn open<F>(
device: &Device,
buffer_frames: u32,
notices: &Arc<Notices>,
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);
let mut config = device.config.config();
config.buffer_size = match device.config.buffer_size() {
cpal::SupportedBufferSize::Range { .. } => cpal::BufferSize::Fixed(buffer_frames),
cpal::SupportedBufferSize::Unknown => cpal::BufferSize::Default,
};
let observed_frames = Arc::new(AtomicU32::new(buffer_frames));
let errors = Arc::clone(notices);
let on_error = move |e: cpal::StreamError| {
// Both variants mean the same thing here: this stream will not produce audio again.
// `service` tears it down and reopens on whatever the default is by then. This is
// the one log on the audio thread, taken knowingly: the stream is already dead, it
// fires once, and the host's own text is the only place the reason exists — the
// notice above can only say "the device went away".
bevy::log::warn!("audio: output stream error ({e})");
errors.device_died.store(true, Ordering::Release);
};
let ctx = Callback {
on_cycle,
channels: usize::from(device.config.channels()),
sample_rate: device.sample_rate,
observed: Arc::clone(&observed_frames),
scratch: vec![0.0; MAX_CYCLE_FRAMES * CHANNELS as usize],
};
// The format list cpal's own `beep` example carries — every output format it can hand a
// typed callback for. The default config names one of them, so in practice this picks
// f32 on WASAPI and whatever ALSA's heuristic settled on.
let stream = match device.config.sample_format() {
cpal::SampleFormat::F32 => build::<f32, _>(device, &config, ctx, on_error),
cpal::SampleFormat::F64 => build::<f64, _>(device, &config, ctx, on_error),
cpal::SampleFormat::I8 => build::<i8, _>(device, &config, ctx, on_error),
cpal::SampleFormat::I16 => build::<i16, _>(device, &config, ctx, on_error),
cpal::SampleFormat::I24 => build::<cpal::I24, _>(device, &config, ctx, on_error),
cpal::SampleFormat::I32 => build::<i32, _>(device, &config, ctx, on_error),
cpal::SampleFormat::I64 => build::<i64, _>(device, &config, ctx, on_error),
cpal::SampleFormat::U8 => build::<u8, _>(device, &config, ctx, on_error),
cpal::SampleFormat::U16 => build::<u16, _>(device, &config, ctx, on_error),
cpal::SampleFormat::U32 => build::<u32, _>(device, &config, ctx, on_error),
cpal::SampleFormat::U64 => build::<u64, _>(device, &config, ctx, on_error),
other => bail!("device {} wants sample format {other}", device.name),
}?;
stream.play().context("starting the output stream")?;
Ok(Self {
_stream: stream,
observed_frames,
})
}
}
/// Everything the audio callback owns, so [`Stream::open`] can build it once and hand it to
/// whichever sample-format instantiation the device turns out to want.
struct Callback<F> {
on_cycle: F,
channels: usize,
sample_rate: u32,
observed: Arc<AtomicU32>,
/// Preallocated interleaved stereo — the buffer [`Cycle`] hands out. Sized at open; a host
/// buffer longer than this is served in several cycles rather than by allocating here.
scratch: Vec<f32>,
}
fn build<T, F>(
device: &Device,
config: &cpal::StreamConfig,
mut ctx: Callback<F>,
on_error: impl FnMut(cpal::StreamError) + Send + 'static,
) -> Result<cpal::Stream>
where
T: SizedSample + FromSample<f32>,
F: FnMut(Cycle<'_>) + Send + 'static,
{
device
.device
.build_output_stream::<T, _, _>(
config,
move |data, info| ctx.run(data, info),
on_error,
None,
)
.with_context(|| format!("opening an output stream on {}", device.name))
}
impl<F: FnMut(Cycle<'_>) + Send + 'static> Callback<F> {
/// One cpal callback: render our stereo mix into the scratch and spread it over the
/// device's channels. Realtime thread — no allocation, no lock, no log.
fn run<T>(&mut self, data: &mut [T], info: &cpal::OutputCallbackInfo)
where
T: SizedSample + FromSample<f32>,
{
let frames = data.len() / self.channels;
if frames == 0 {
return;
}
self.observed.store(frames as u32, Ordering::Relaxed);
// The device timeline, on our clock: how far ahead of this callback the audio it fills
// is due. `duration_since` is `None` only if a host reports playback before callback,
// which would make the lead meaningless — report no timestamp rather than a wrong one.
let ts = info.timestamp();
let due = match ts.playback.duration_since(&ts.callback) {
Some(ahead) => now_ns() + ahead.as_nanos() as u64,
None => 0,
};
let per_cycle = self.scratch.len() / CHANNELS as usize;
let mut done = 0;
while done < frames {
let take = (frames - done).min(per_cycle);
let stereo = &mut self.scratch[..take * CHANNELS as usize];
(self.on_cycle)(Cycle {
buffer: stereo,
frames: take,
// A split buffer's later parts are due later; a zero stamp stays zero.
output_time_ns: if due == 0 {
0
} else {
due + (done as u64 * 1_000_000_000) / u64::from(self.sample_rate.max(1))
},
});
spread(stereo, &mut data[done * self.channels..], self.channels);
done += take;
}
}
}
/// Write an interleaved stereo block over a device buffer of `channels` channels.
///
/// Stereo is the mix's own shape, and every host here takes the device's channel count
/// literally — there is no HAL output unit above us doing the map, the way `coreaudio.rs` has.
/// So: mono averages the pair, stereo copies, and anything wider puts L and R on the first two
/// channels and silences the rest. That last case is deliberately *not* an upmix — the
/// reference is a stereo client, and inventing a centre or a surround feed would be our
/// invention, not its.
fn spread<T: SizedSample + FromSample<f32>>(stereo: &[f32], out: &mut [T], channels: usize) {
match channels {
1 => {
for (frame, slot) in stereo.chunks_exact(2).zip(out.iter_mut()) {
*slot = T::from_sample((frame[0] + frame[1]) * 0.5);
}
}
2 => {
for (sample, slot) in stereo.iter().zip(out.iter_mut()) {
*slot = T::from_sample(*sample);
}
}
n => {
for (frame, slot) in stereo.chunks_exact(2).zip(out.chunks_exact_mut(n)) {
slot[0] = T::from_sample(frame[0]);
slot[1] = T::from_sample(frame[1]);
for quiet in &mut slot[2..] {
*quiet = T::from_sample(0.0f32);
}
}
}
}
}
// ---------------------------------------------------------------------------------------------
// Listeners
/// The flags the device watch raises; the backend polls them on the main thread. Same shape as
/// `coreaudio.rs`'s, so [`super`] reacts identically — with one field that stays zero here
/// ([`Notices::overloads`]: no host below has an analogue of `kAudioDeviceProcessorOverload`,
/// so the audible failure is counted as a ring underrun and nowhere else).
#[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,
}
/// The device watch: a thread that re-reads the host's default output every [`WATCH_EVERY`] and
/// raises a notice when it is no longer the device we are on. cpal has no notification API —
/// this poll is the mechanism kira's own cpal backend uses off macOS, and it is why it was
/// compiled out *on* macOS (kira #38: enumerating devices mid-stream crackles there). One-shot:
/// it stops at the first notice, because the backend's answer to one is to rebuild the stream,
/// which arms a fresh watch on the device it landed on.
pub(super) struct Listeners {
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Listeners {
pub(super) fn arm(device: &Device, notices: Arc<Notices>) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let watching = Arc::clone(&stop);
let (was_id, was_name, was_rate) =
(device.id.clone(), device.name.clone(), device.sample_rate);
let thread = std::thread::Builder::new()
.name("audio-devwatch".into())
.spawn(move || {
while !watching.load(Ordering::Acquire) {
std::thread::park_timeout(WATCH_EVERY);
if watching.load(Ordering::Acquire) {
return;
}
let Some(now) = cpal::default_host().default_output_device() else {
notices.device_died.store(true, Ordering::Release);
return;
};
// Identity by the host's own id where there is one, by display name where
// there is not. A device we could not read at all is a transient, not a
// change — `None` here says nothing and looks again next tick, rather than
// forcing a rebuild onto the device we are already on.
let changed = match (&was_id, now.id()) {
(Some(was), Ok(is_now)) => Some(*was != is_now),
_ => now.description().ok().map(|d| d.name() != was_name),
};
if changed == Some(true) {
notices.default_changed.store(true, Ordering::Release);
return;
}
if now
.default_output_config()
.is_ok_and(|c| c.sample_rate() != was_rate)
{
notices.rate_changed.store(true, Ordering::Release);
return;
}
}
})
.ok();
if thread.is_none() {
// A watch we could not spawn is a degraded instrument, not a dead stream: the mix
// plays, it just will not follow a device change until the next open.
bevy::log::warn!("audio: no device watch — device changes will not be followed");
}
Self { stop, thread }
}
}
impl Drop for Listeners {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(thread) = self.thread.take() {
thread.thread().unpark();
let _ = thread.join();
}
}
}
// ---------------------------------------------------------------------------------------------
// Realtime scheduling for the render thread
/// No audio workgroups off macOS — no scheduler here has a notion of "the threads working
/// toward this device's deadline". [`set_realtime`] is the whole story.
pub(super) struct Workgroup;
impl Workgroup {
pub(super) fn of_device(_device: &Device) -> Option<Self> {
None
}
}
pub(super) struct Joined;
impl Joined {
pub(super) fn join(_group: Workgroup) -> Result<Self> {
bail!("no audio workgroups on this platform")
}
}
/// The render thread's realtime standing, released on drop.
pub(super) struct Realtime {
#[cfg(windows)]
_task: mmcss::Task,
}
/// Give the calling thread the strongest scheduling standing the OS will grant an audio worker.
/// It may be refused, and [`super`]'s caller then falls back — the mix-ahead ring is what makes
/// that a degradation rather than a crackle.
///
/// - **unix** (Linux, the BSDs): `SCHED_FIFO`. Refused with `EPERM` unless the process has
/// `RLIMIT_RTPRIO` headroom — which a desktop session gets from its audio group or from
/// PipeWire's own limits file, and a bare login shell does not. Priority 10 is deliberately
/// modest: it is inside the ceiling rtkit hands its own clients (20), so we sit below the
/// sound server's threads rather than above them. There is no busy-wait to starve a core
/// with — the loop parks between chunks.
/// - **Windows**: MMCSS, task "Pro Audio" at `AVRT_PRIORITY_CRITICAL` — the documented way for
/// a thread that is not the audio engine's own to be scheduled like one, and what every
/// Windows audio stack (WASAPI's own samples, cpal, `audio_thread_priority`) asks for.
///
/// `period_ns` is the render chunk's duration; only a time-constraint scheduler (macOS) takes
/// one, so it is unused here.
pub(super) fn set_realtime(_period_ns: u64) -> Result<Realtime> {
#[cfg(all(unix, not(target_os = "macos")))]
{
// SAFETY: a POSIX call against the calling thread with a zeroed, correctly typed
// parameter block — `sched_param` carries reserved fields on some targets, so it is
// zeroed rather than listed.
let rc = unsafe {
let mut param: libc::sched_param = std::mem::zeroed();
param.sched_priority = 10;
libc::pthread_setschedparam(libc::pthread_self(), libc::SCHED_FIFO, &param)
};
if rc != 0 {
let why = std::io::Error::from_raw_os_error(rc);
bail!("pthread_setschedparam(SCHED_FIFO, 10) failed: {why}");
}
Ok(Realtime {})
}
#[cfg(windows)]
{
Ok(Realtime {
_task: mmcss::Task::join("Pro Audio")?,
})
}
#[cfg(not(any(all(unix, not(target_os = "macos")), windows)))]
{
bail!("no realtime thread policy on this platform")
}
}
/// MMCSS, declared here rather than pulled through a binding crate — three calls and a constant
/// are not worth a dependency, and it is the posture `coreaudio.rs` already takes with mach and
/// `os_workgroup`.
#[cfg(windows)]
mod mmcss {
use anyhow::{bail, Result};
/// `AVRT_PRIORITY_CRITICAL`.
const PRIORITY_CRITICAL: i32 = 2;
#[link(name = "avrt")]
extern "system" {
fn AvSetMmThreadCharacteristicsW(
task_name: *const u16,
task_index: *mut u32,
) -> *mut core::ffi::c_void;
fn AvSetMmThreadPriority(handle: *mut core::ffi::c_void, priority: i32) -> i32;
fn AvRevertMmThreadCharacteristics(handle: *mut core::ffi::c_void) -> i32;
}
/// The calling thread's membership in an MMCSS task, reverted on drop — on the same thread,
/// which is where the render loop drops it.
pub(super) struct Task(*mut core::ffi::c_void);
// SAFETY: the handle is only ever touched by the thread that created it. The impl exists so
// the enclosing `Realtime` has the same auto-traits on every target, not to move it.
unsafe impl Send for Task {}
impl Task {
pub(super) fn join(name: &str) -> Result<Self> {
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
let mut index = 0u32;
// SAFETY: a NUL-terminated wide string and an out-parameter, per the API contract.
let handle = unsafe { AvSetMmThreadCharacteristicsW(wide.as_ptr(), &mut index) };
if handle.is_null() {
bail!("AvSetMmThreadCharacteristicsW(\"{name}\") was refused");
}
// SAFETY: `handle` is the live registration just returned.
if unsafe { AvSetMmThreadPriority(handle, PRIORITY_CRITICAL) } == 0 {
// The task membership alone is most of the benefit; keep it, and say so.
bevy::log::warn!("audio: MMCSS took the task but refused AVRT_PRIORITY_CRITICAL");
}
Ok(Self(handle))
}
}
impl Drop for Task {
fn drop(&mut self) {
// SAFETY: mirrors the registration above, exactly once.
unsafe { AvRevertMmThreadCharacteristics(self.0) };
}
}
}
+134 -33
View File
@@ -1,8 +1,14 @@
//! The output stream — benilla's own kira backend (decision 1857).
//! The output stream — benilla's own kira backend (decision 1857, extended to every platform
//! by 1920).
//!
//! 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 sound reached the hardware on time. **All of it is the same code everywhere.** Exactly
//! one seam is platform-specific — `device`, which finds the default output, opens a stream on
//! it, and hands the callback one buffer per cycle: `coreaudio.rs` on macOS, `cpal.rs`
//! elsewhere. Nothing above that seam knows which one it is, which is the point: 1857 wrote the
//! non-macOS half as a stub that opened nothing, and Linux and Windows shipped silent for a
//! release (B356).
//!
//! ## The shape: mix ahead, copy on the deadline
//!
@@ -12,16 +18,20 @@
//! `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.
//! milliseconds inside the callback is a skipped cycle, and a skipped cycle is a crackle. The
//! mechanism was measured on CoreAudio; every host below has the same shape, and the answer
//! below is the same one.
//!
//! 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.
//! render thread is scheduled the way the platform documents for an audio worker
//! (`device::set_realtime`): on macOS a time-constraint policy joined to the device's IO
//! workgroup, on unix `SCHED_FIFO`, on Windows MMCSS — so under CPU contention it is served
//! like the audio system's own thread. Every one of those can be refused, and the ring's depth
//! is what makes a refusal a degradation rather than a crackle.
//!
//! ## What the meters can now say
//!
@@ -37,22 +47,30 @@
//! 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.
//! macOS only: no other host has an analogue, and off macOS this reads zero. Every other
//! meter, `underruns` included, means the same thing everywhere.
//!
//! ## 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.
//! for the default device) — unplug the headphones and the old stream paused forever. That is
//! why `coreaudio.rs` exists and why cpal is out of the graph on macOS; off macOS the notices
//! come from a device poll and cpal's error callback, which those hosts do deliver, and the
//! rebuild loop above is the same one.
//!
//! 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.
// The device layer, one file per platform behind one name. Everything in this module is the
// same code everywhere; `device` is the only seam (decision 1857 for CoreAudio, 1920 for cpal).
#[cfg(target_os = "macos")]
mod coreaudio;
#[path = "coreaudio.rs"]
mod device;
#[cfg(not(target_os = "macos"))]
#[path = "stub.rs"]
mod coreaudio;
#[path = "cpal.rs"]
mod device;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
@@ -63,7 +81,7 @@ use bevy::log::{debug, warn};
use kira::backend::{Backend, Renderer};
use rtrb::{Consumer, Producer, RingBuffer};
use coreaudio::{Cycle, Device, Joined, Listeners, Notices, Stream, Workgroup};
use device::{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
@@ -102,6 +120,9 @@ pub(super) const MIX_AHEAD_MS: u32 = 100;
static DEVICE_OPEN: AtomicBool = AtomicBool::new(false);
/// True while an output stream is open on a device.
// The one reader is the stall watchdog (`perf::stall`), which is macOS-only — it exists because
// `/usr/bin/sample` suspends the whole task. Off macOS nothing asks, and that is not drift.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn device_open() -> bool {
DEVICE_OPEN.load(Ordering::Acquire)
}
@@ -109,7 +130,7 @@ pub(crate) fn device_open() -> bool {
/// 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)
device::default_output().ok().map(|d| d.sample_rate)
}
/// The dials the mixer opens the device with.
@@ -313,7 +334,7 @@ impl Backend for OutputBackend {
type Error = anyhow::Error;
fn setup(settings: Self::Settings, _internal_buffer_size: usize) -> Result<(Self, u32)> {
let device = coreaudio::default_output()?;
let device = device::default_output()?;
let shared = Arc::new(Shared::default());
shared.meters.reset_extrema();
let sample_rate = device.sample_rate;
@@ -347,7 +368,7 @@ impl Backend for OutputBackend {
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 group = Workgroup::of_device(&device);
let shared = Arc::clone(&self.shared);
let period_ns = frames_to_ns(RENDER_CHUNK_FRAMES, self.sample_rate);
let handle = std::thread::Builder::new()
@@ -409,7 +430,7 @@ impl OutputBackend {
}
if let Stage::Idle { since, .. } = &self.stage {
if since.elapsed() >= REOPEN_EVERY {
match coreaudio::default_output() {
match device::default_output() {
Ok(device) => {
if let Some(event) = self.open(device) {
events.push(event);
@@ -453,23 +474,28 @@ impl OutputBackend {
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) {
match Stream::open(
&device,
self.settings.device_buffer_frames,
&self.shared.notices,
on_cycle,
) {
Ok(stream) => {
self.buffer_frames = stream.buffer_frames;
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 Some(group) = Workgroup::of_device(&device) {
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));
let listeners = Listeners::arm(&device, 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,
buffer_frames: stream.buffer_frames(),
mix_ahead_ms: self.settings.mix_ahead_ms,
realtime_latency_frames: device.latency_frames + device.safety_frames,
};
@@ -524,6 +550,12 @@ impl OutputBackend {
}
fn take_window(&mut self) -> Window {
// The running cycle size, not the one we asked for: a host may grant another (the HAL's
// silent clamp), and WASAPI's shared mode varies it per wake. The report's `nominal` is
// only worth printing if it is what just happened.
if let Stage::Running { stream, .. } = &self.stage {
self.buffer_frames = stream.buffer_frames();
}
let m = &self.shared.meters;
let rate = f64::from(self.sample_rate.max(1));
let frames_ms = |frames: f64| frames / rate * 1000.0;
@@ -535,7 +567,7 @@ impl OutputBackend {
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))
ns_ms(device::now_ns().saturating_sub(at))
});
Window {
cycles: m.cycles.swap(0, Ordering::Relaxed),
@@ -615,7 +647,7 @@ fn io_cycle(
last_output_ns: &mut u64,
) {
no_alloc(|| {
let entry = coreaudio::now_ns();
let entry = device::now_ns();
let meters = &shared.meters;
if cycle.output_time_ns != 0 {
let lead = cycle.output_time_ns as i64 - entry as i64;
@@ -654,13 +686,18 @@ fn io_cycle(
meters.cycles.fetch_add(1, Ordering::Relaxed);
meters
.io_wall_max_ns
.fetch_max(coreaudio::now_ns().saturating_sub(entry), Ordering::Relaxed);
.fetch_max(device::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).
// The two explicit `drop`s below are load-bearing on macOS, where `Joined` leaves the device's
// audio workgroup in its `Drop`: the old membership goes before the new one is taken, never both
// at once. Off macOS `Joined` cannot even be constructed, so clippy sees a drop of a type with no
// `Drop` — a platform difference, not a redundant call.
#[allow(clippy::drop_non_drop)]
fn render_loop(
mut renderer: Renderer,
mut producer: Producer<f32>,
@@ -668,18 +705,24 @@ fn render_loop(
group: Option<Workgroup>,
period_ns: u64,
) {
match coreaudio::set_realtime(period_ns) {
Ok(()) => debug!(
"audio: render thread scheduled realtime, period {} µs",
period_ns / 1000
),
// Held for the thread's life and dropped with it: on Windows that hands the MMCSS
// registration back, everywhere else it is inert.
let _realtime = match device::set_realtime(period_ns) {
Ok(realtime) => {
debug!(
"audio: render thread scheduled realtime, period {} µs",
period_ns / 1000
);
Some(realtime)
}
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,
);
None
}
}
};
let mut joined = group.and_then(|g| match Joined::join(g) {
Ok(j) => {
debug!("audio: render thread joined the device's IO workgroup");
@@ -690,7 +733,7 @@ fn render_loop(
None
}
});
let mut chunk = vec![0f32; RENDER_CHUNK_FRAMES * coreaudio::CHANNELS as usize];
let mut chunk = vec![0f32; RENDER_CHUNK_FRAMES * device::CHANNELS as usize];
let period = Duration::from_nanos(period_ns);
let control = &shared.control;
let meters = &shared.meters;
@@ -714,10 +757,10 @@ fn render_loop(
}
}
while producer.slots() >= chunk.len() {
let start = coreaudio::now_ns();
let start = device::now_ns();
no_alloc(|| {
renderer.on_start_processing();
renderer.process(&mut chunk, coreaudio::CHANNELS as u16);
renderer.process(&mut chunk, device::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()]);
@@ -728,9 +771,67 @@ fn render_loop(
meters.render_chunks.fetch_add(1, Ordering::Relaxed);
meters
.render_wall_max_ns
.fetch_max(coreaudio::now_ns().saturating_sub(start), Ordering::Relaxed);
.fetch_max(device::now_ns().saturating_sub(start), Ordering::Relaxed);
}
std::thread::park_timeout(period);
}
drop(joined);
}
#[cfg(test)]
mod tests {
use super::*;
/// **The instrument B356 was missing.** Opt-in (`WOW_AUDIO_LIVE=1`), because it opens the
/// machine's real output device — not something eight parallel worktrees should each do on
/// every `cargo test`. What it answers is the one question the gates cannot: *on this
/// platform, does the device layer open a stream, does the IO callback run, and does the
/// render thread keep the ring ahead of it?* Every layer of decision 1857 is in the loop
/// and nothing above it is, so a failure names the platform file and nothing else.
///
/// It is the whole verification story off macOS, where the gates never compile this code at
/// all: `scripts/crosscheck.sh` type-checks Linux and Windows, and this runs the result.
///
/// WOW_AUDIO_LIVE=1 cargo test -p benilla-app --lib sound::output:: -- --nocapture
///
/// **A device with no clock fails the underrun assertion by construction, and that is the
/// assertion working.** Run against ALSA's `null` PCM (the obvious way to fake a device in a
/// container) the first run of this test read 1,192,964 cycles in one second against a
/// nominal 86: `null` accepts every write instantly, so the callback free-runs and no render
/// thread on earth stays ahead of it. A sink that paces — PulseAudio's `module-null-sink`,
/// `pcm.!default { type pulse }` — reads 0 underruns with the ring at its full 106 ms. If
/// this fails with a cycle count wildly above `1 s / cycle_ms`, look at the device before
/// the code.
#[test]
fn live_output_opens_and_runs() {
if std::env::var("WOW_AUDIO_LIVE").is_err() {
eprintln!("skipped: set WOW_AUDIO_LIVE=1 to open the real output device");
return;
}
let mut manager = kira::AudioManager::<OutputBackend>::new(kira::AudioManagerSettings::<
OutputBackend,
>::default())
.expect("the output backend opened a device");
// One second of real device cycles. The stream opens inside `start`, so the first
// `service` already carries the `Opened` event.
std::thread::sleep(Duration::from_secs(1));
let (window, events) = manager.backend_mut().service();
for event in &events {
eprintln!("event: {event:?}");
}
eprintln!("window: {window:?}");
assert!(
events.iter().any(|e| matches!(e, Event::Opened { .. })),
"no device was opened: {events:?}"
);
assert!(window.cycles > 0, "the IO callback never ran: {window:?}");
assert!(
window.render_chunks > 0,
"the render thread never produced a chunk: {window:?}"
);
assert_eq!(
window.underruns, 0,
"the ring ran dry — the render thread is not keeping ahead: {window:?}"
);
}
}
@@ -1,89 +0,0 @@
//! 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")
}
+1 -1
View File
@@ -617,7 +617,7 @@ pub(super) struct CommitOutcome {
/// TAB, so `engaged && had_old` covers the same observable; our `engaged` is the server-echoed
/// [`Engaged`], not the ref's instant local lock (one-RTT lag on a switch fired mid-attack-
/// start); and the ref's invalid-new-target Attack sends a second (duplicate) ATTACKSTOP we
/// don't. Shared with the UI's `TargetUnit` drain ([`super::target_unit_requests`]) so every
/// don't. Shared with the UI's `TargetUnit` drain (`super::target_unit_requests`) so every
/// non-mouse selection writer commits identically — a `TargetUnit("player")` mid-combat stops
/// the swing and does NOT re-point (the tail's self exception).
pub(super) fn commit(
+32 -6
View File
@@ -314,6 +314,24 @@ fn send_spell_cast(
if reagent_totem_refusal(spell_id, def, ctx.rel.self_store, items, cast_errors) {
return;
}
// **TryCast rung 7** (`0x6e4e03`, decision 1925) — the equipped-item requirement, which the
// cast path did not have at all: the greying ladder correctly greyed the button and pressing
// it still sent. The search is the same one the greying leg and the tooltip requirement line
// use, so all three now agree, and the reason it refuses with is `AttributesEx3`'s own pick
// ([`super::usable::equipped_item_reason`]).
//
// Position is the reference's: four rungs above the crowd-control validator below, so a
// player who is both stunned and missing the required weapon is told about **the weapon**.
if let Some(d) = def {
if let Some(store) = ctx.rel.self_store {
if !super::usable::equipped_item_fits_cached(d, store, items) {
let reason = super::usable::equipped_item_reason(d);
debug!("ui_action: cast {spell_id} refused locally — equipped item ({reason:#x})");
cast_errors.push_local(spell_id, reason);
return;
}
}
}
// ArmCast (`0x6e5250`): resolve the wire target from the spell's targeting constraints —
// never the raw selection ([`cast_target`] module docs). A refusal is local and pre-commit,
// like the ref's residual flag_word: no send, no GCD, no pending arm, no autorepeat key.
@@ -472,12 +490,20 @@ fn send_spell_cast(
}
}
}
// The CROWD-CONTROL leg of the requirement validator `0x6094f0` (decision 1903), which sits
// ABOVE its mounted block below — so a stunned mounted caster reads the stun. STUNNED refuses
// every spell; SILENCED and PACIFIED only the rows whose `PreventionType` names them.
if let Some(reason) =
state::cast_cc_refusal(ctx.rel.self_store.map_or(0, |s| s.0.unit_flags()), def)
{
// The CROWD-CONTROL leg of the requirement validator `0x6094f0` (decision 1903, widened from
// three arms to the reference's six by 1925), which sits ABOVE its mounted block below — so a
// stunned mounted caster reads the stun.
let self_fields = ctx.rel.self_store.map(|s| &s.0);
if let Some(reason) = state::cast_cc_refusal(
self_fields.map_or(0, |f| f.unit_flags()),
self_fields.and_then(|f| f.unit_health()),
// The charm arm asks whether somebody ELSE holds the reins (`60994d`'s active-player
// compare) — a self-charm is not something the reference refuses on.
self_fields
.and_then(|f| f.unit_charmed_by())
.is_some_and(|charmer| Some(charmer) != ctx.self_guid),
def,
) {
debug!("ui_action: cast {spell_id} refused locally — crowd control ({reason:#x})");
cast_errors.push_local(spell_id, reason);
return;
+133 -42
View File
@@ -61,7 +61,7 @@ pub(super) struct StateMemory {
}
/// The client's cast-fail reasons for the two range refusals ("Out of range." / "Target too
/// close" in [`super::cast_error_text`]'s table) — what `CanTargetUnit 0x6e4440` emits when
/// close" in `super::cast_error_text`'s table) — what `CanTargetUnit 0x6e4440` emits when
/// `IsTargetInRange 0x6e47b0` fails on its max² / min² compare.
pub(super) const ERR_OUT_OF_RANGE: u8 = 0x59;
pub(super) const ERR_TOO_CLOSE: u8 = 0x76;
@@ -97,37 +97,77 @@ const PREVENTION_SILENCE: u32 = 1;
const PREVENTION_PACIFY: u32 = 2;
/// The **crowd-control leg** of the same requirement validator `0x6094f0`, sitting **above** its
/// mounted block (`0x609c6c`) — so a stunned mounted caster is told about the stun (decision 1903;
/// wow-re `disarm-followups-law.md` §3, byte-verified). It refuses before any packet, which is why
/// it must be local: reason codes `0x64` STUNNED, `0x60` SILENCED, `0x5a` PACIFIED.
/// mounted block (`0x609c6c`) — so a stunned mounted caster is told about the stun (decision 1904;
/// wow-re `equipped-item-and-cc-cast-gates.md` §2.1, byte-verified). It refuses before any packet,
/// which is why it must be local.
///
/// **The three arms are not symmetric, and that is the whole finding.** STUNNED (`0x6099bc`)
/// carries no per-spell gate and refuses *everything*. SILENCED (`0x609a29`) and PACIFIED run only
/// where the spell's `PreventionType` declares them — so a silence stops casts and leaves melee
/// abilities alone, and a pacify does the reverse. Reading `UNIT_FLAG_SILENCED` as "no casting"
/// is the mistake this replaces.
/// **Six arms, in the reference's order, first match wins** — 1863's fold-back recorded four:
///
/// Its byte-shape was nearly missed by a census twice: the read is `f6 c4 20 test ah,0x20`, a
/// sub-register byte-lane form with no dword immediate, invisible to an immediate scan.
/// | # | arm | gate | reason |
/// |---|---|---|---|
/// | 1 | CHARMED | `UNIT_FIELD_CHARMEDBY != 0` and the charmer is not us | `0x14` |
/// | 2 | STUNNED | bit 18 — **no per-spell gate, every spell** | `0x64` |
/// | 3 | SILENCED | bit 13 **and** `PreventionType == 1` | `0x60` |
/// | 4 | PACIFIED | bit 17 **and** `PreventionType == 2` | `0x5a` |
/// | 5 | FLEEING | bit 23 | `0x1e` |
/// | 6 | CONFUSED | bit 22 | `0x16` |
///
/// **Unmodelled:** the aura exemption (`0x6e9e60`) that waives an arm and answers `0x8d` with a
/// mechanic value instead — it needs an aura-mechanic join we do not have.
pub(crate) fn cast_cc_refusal(unit_flags: u32, spell: Option<&SpellDisplay>) -> Option<u8> {
/// The asymmetry is the finding: STUNNED, FLEEING, CHARMED and CONFUSED carry no per-spell gate at
/// all, while SILENCED and PACIFIED run only where the spell's own `PreventionType` names them —
/// so a silence stops casts and leaves melee abilities alone, and a pacify does the reverse.
/// Reading `UNIT_FLAG_SILENCED` as "no casting" is the mistake this replaces.
///
/// **A dead caster skips all six** (`0x60980d`, a `jle` on `UNIT_FIELD_HEALTH`).
///
/// This ladder is **TryCast-only** — one caller, no address-takes — so a button does **not** grey
/// while stunned, silenced or pacified: it refuses on the press. (Fear, confuse and charm *are*
/// greyed, by a second copy of the same helpers inside `0x6e3d60`; that copy is not built here.)
///
/// Its byte-shape was nearly missed by a census twice: SILENCED's read is `f6 c4 20 test ah,0x20`,
/// a sub-register byte-lane form with no dword immediate, invisible to an immediate scan.
///
/// **Unmodelled, and now known to be buildable rather than out of reach:** each arm first asks an
/// exemption helper whether any of the caster's own auras grants immunity — a scan of
/// `UNIT_FIELD_AURA[0..47]`'s spell ids against `EffectApplyAuraName` (Spell.dbc columns 91–93).
/// A hit **lifts** the refusal; it never creates one, and a set flag with no matching aura still
/// refuses with the generic reason above. So omitting it can only refuse where the reference would
/// have allowed — never the reverse.
pub(crate) fn cast_cc_refusal(
unit_flags: u32,
health: Option<u32>,
charmed_by_other: bool,
spell: Option<&SpellDisplay>,
) -> Option<u8> {
use crate::player::UNIT_FLAG_STUNNED;
/// `UNIT_FLAG_SILENCED` / `UNIT_FLAG_PACIFIED` (vmangos `UnitDefines.h`).
/// `UNIT_FIELD_FLAGS` bits 13/17/22/23 (vmangos `UnitDefines.h`).
const UNIT_FLAG_SILENCED: u32 = 0x0000_2000;
const UNIT_FLAG_PACIFIED: u32 = 0x0002_0000;
const UNIT_FLAG_CONFUSED: u32 = 0x0040_0000;
const UNIT_FLAG_FLEEING: u32 = 0x0080_0000;
// The dead caster's skip (`0x60980d`): a corpse is refused by an earlier rung, not this one.
if health == Some(0) {
return None;
}
let prevention = spell.map_or(0, |d| d.prevention_type);
if charmed_by_other {
return Some(0x14);
}
if unit_flags & UNIT_FLAG_STUNNED != 0 {
return Some(0x64);
}
let prevention = spell.map_or(0, |d| d.prevention_type);
if unit_flags & UNIT_FLAG_SILENCED != 0 && prevention == PREVENTION_SILENCE {
return Some(0x60);
}
if unit_flags & UNIT_FLAG_PACIFIED != 0 && prevention == PREVENTION_PACIFY {
return Some(0x5a);
}
if unit_flags & UNIT_FLAG_FLEEING != 0 {
return Some(0x1e);
}
if unit_flags & UNIT_FLAG_CONFUSED != 0 {
return Some(0x16);
}
None
}
@@ -1112,6 +1152,12 @@ mod tests {
mod cc_refusal_tests {
use super::*;
const STUNNED: u32 = 0x0004_0000;
const SILENCED: u32 = 0x0000_2000;
const PACIFIED: u32 = 0x0002_0000;
const CONFUSED: u32 = 0x0040_0000;
const FLEEING: u32 = 0x0080_0000;
fn spell(prevention_type: u32) -> SpellDisplay {
SpellDisplay {
prevention_type,
@@ -1119,47 +1165,92 @@ mod cc_refusal_tests {
}
}
/// **The three arms are not symmetric** (decision 1903): a stun refuses everything, a silence
/// only `PreventionType 1`, a pacify only `2`. Reading `UNIT_FLAG_SILENCED` as "no casting at
/// all" — which is what our own preflight banner used to say — is the mistake this pins.
#[test]
fn crowd_control_refuses_by_prevention_type_except_the_stun() {
const STUNNED: u32 = 0x0004_0000;
const SILENCED: u32 = 0x0000_2000;
const PACIFIED: u32 = 0x0002_0000;
/// A live caster, nobody else at the reins.
fn cc(flags: u32, d: &SpellDisplay) -> Option<u8> {
cast_cc_refusal(flags, Some(100), false, Some(d))
}
// Fireball-shaped (silence-preventable) and Heroic-Strike-shaped (pacify-preventable),
// and the auto-attack's neither — the three real values, pinned in `spell_catalog`.
/// **The arms are not symmetric** (decision 1904, widened by 1925): stun, fear, charm and
/// confuse refuse EVERY spell; silence and pacify only the rows whose `PreventionType` names
/// them. Reading `UNIT_FLAG_SILENCED` as "no casting at all" — which our own preflight banner
/// used to say — is the mistake this pins.
#[test]
fn crowd_control_refuses_by_prevention_type_except_the_unconditional_arms() {
// Fireball-shaped (silence-preventable), Heroic-Strike-shaped (pacify-preventable), and
// the auto-attack's neither — the three real values, pinned in `spell_catalog`.
let cast = spell(1);
let melee = spell(2);
let neither = spell(0);
// STUNNED carries no per-spell gate: every row refuses, `PreventionType` or not.
for d in [&cast, &melee, &neither] {
assert_eq!(cast_cc_refusal(STUNNED, Some(d)), Some(0x64));
// The four arms with NO per-spell gate: every row refuses.
for (flags, reason) in [(STUNNED, 0x64), (FLEEING, 0x1e), (CONFUSED, 0x16)] {
for d in [&cast, &melee, &neither] {
assert_eq!(cc(flags, d), Some(reason), "flags {flags:#x}");
}
}
// …and charm, which is not a flag at all but "somebody else holds the reins".
assert_eq!(
cast_cc_refusal(0, Some(100), true, Some(&neither)),
Some(0x14)
);
// SILENCED takes the casts and leaves the rest alone.
assert_eq!(cast_cc_refusal(SILENCED, Some(&cast)), Some(0x60));
assert_eq!(cast_cc_refusal(SILENCED, Some(&melee)), None);
assert_eq!(cast_cc_refusal(SILENCED, Some(&neither)), None);
assert_eq!(cc(SILENCED, &cast), Some(0x60));
assert_eq!(cc(SILENCED, &melee), None);
assert_eq!(cc(SILENCED, &neither), None);
// PACIFIED is the mirror.
assert_eq!(cast_cc_refusal(PACIFIED, Some(&melee)), Some(0x5a));
assert_eq!(cast_cc_refusal(PACIFIED, Some(&cast)), None);
assert_eq!(cc(PACIFIED, &melee), Some(0x5a));
assert_eq!(cc(PACIFIED, &cast), None);
// The stun wins when several hold at once — it is the first arm.
assert_eq!(
cast_cc_refusal(STUNNED | SILENCED | PACIFIED, Some(&cast)),
Some(0x64)
);
// Nothing up, nothing refused; and no record claims no prevention.
assert_eq!(cast_cc_refusal(0, Some(&cast)), None);
assert_eq!(cast_cc_refusal(SILENCED, None), None);
assert_eq!(cc(0, &cast), None);
assert_eq!(cast_cc_refusal(SILENCED, Some(100), false, None), None);
assert_eq!(
cast_cc_refusal(STUNNED, None),
cast_cc_refusal(STUNNED, Some(100), false, None),
Some(0x64),
"the stun needs no record"
);
}
/// **The order is the reference's, first match wins** — charm outranks the stun, the stun
/// outranks everything below it. When several hold at once the player sees exactly one line,
/// and which one is not arbitrary.
#[test]
fn the_arms_are_tried_in_the_references_order() {
let cast = spell(1);
// Charm is arm 1: it beats a simultaneous stun.
assert_eq!(
cast_cc_refusal(STUNNED | SILENCED, Some(100), true, Some(&cast)),
Some(0x14)
);
// Stun is arm 2: it beats silence, pacify, fear and confuse.
assert_eq!(
cc(STUNNED | SILENCED | PACIFIED | FLEEING | CONFUSED, &cast),
Some(0x64)
);
// Silence (3) beats fear (5) and confuse (6).
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &cast), Some(0x60));
// With the spell out of silence's reach, fear takes it before confuse.
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &spell(0)), Some(0x1e));
}
/// **A dead caster skips all six** (`0x60980d`'s `jle` on `UNIT_FIELD_HEALTH`) — a corpse is
/// refused by an earlier rung, and reporting a stun over it would be the wrong line.
#[test]
fn a_dead_caster_takes_no_crowd_control_refusal() {
let cast = spell(1);
assert_eq!(cast_cc_refusal(STUNNED, Some(0), false, Some(&cast)), None);
assert_eq!(cast_cc_refusal(0, Some(0), true, Some(&cast)), None);
// Alive again, and the arm is back.
assert_eq!(
cast_cc_refusal(STUNNED, Some(1), false, Some(&cast)),
Some(0x64)
);
// No health field at all is not death — the descriptor simply has not landed.
assert_eq!(
cast_cc_refusal(STUNNED, None, false, Some(&cast)),
Some(0x64)
);
}
}
+152 -28
View File
@@ -67,6 +67,85 @@ const ATTR_EX3_OFF_HAND_ONLY: u32 = 0x0100_0000;
/// matching its class. The other is being genuinely broken.
const ITEM_FLAG_DEPRECATED: u32 = 0x0000_0010;
/// `TARGET_FLAG_ITEM` (`Targets`, column 13, bit 4) — an item-targeting spell. Its
/// `EquippedItem*` columns describe the **clicked item**, not the caster's gear, so the
/// equipped-item search short-circuits on it (`0x6e40e0`'s second gate).
const TARGET_FLAG_ITEM: u32 = 0x0000_0010;
/// The search's **slot mask**, from `AttributesEx3` (`0x6e4136`–`0x6e4153`): bit 10 → equipment
/// index 15 alone, else bit 24 → index 16 alone, else every slot.
fn hand_mask(d: &SpellDisplay) -> u32 {
if d.attributes_ex3 & ATTR_EX3_MAIN_HAND_ONLY != 0 {
1 << crate::items::EQUIPMENT_SLOT_MAINHAND
} else if d.attributes_ex3 & ATTR_EX3_OFF_HAND_ONLY != 0 {
1 << crate::items::EQUIPMENT_SLOT_OFFHAND
} else {
u32::MAX
}
}
/// [`equipped_item_fits`]'s **read-only** twin, for the cast ladder's rung 7: the same search over
/// the same slots, but it never asks the server for a missing template — it runs inside a `&Items`
/// borrow. By the time a button is pressed the greying feed that shares this search has had the
/// template for many frames, and an uncached one is the shared benefit-of-the-doubt anyway.
pub(crate) fn equipped_item_fits_cached(
d: &SpellDisplay,
store: &ObjectStore,
items: &Items,
) -> bool {
if d.equipped_item_class < 0
|| d.equipped_item_subclass_mask == 0
|| d.targets & TARGET_FLAG_ITEM != 0
{
return true;
}
let mut mask = hand_mask(d);
if let Some(hidden) = crate::items::disarmed_equipment_slot_cached(store, items) {
mask &= !(1u32 << hidden);
}
equipped_slots_match(
store,
mask,
d.equipped_item_class as u32,
d.equipped_item_subclass_mask,
|guid| {
let obj = items.object(guid)?;
let t = items.template_cached(obj.object_entry()?)?;
Some(WornItem {
class: t.class,
subclass: t.subclass,
flags: t.flags,
durability: obj.item_durability(),
max_durability: obj.item_max_durability(),
})
},
)
}
/// **Which equipped-item reason the cast refuses with** — TryCast rung 7's own selection
/// (`0x6e40e0` @ `6e416e`–`6e4180`, decision 1925). It is keyed on `AttributesEx3` **alone**: not
/// on `EquippedItemClass`, and not on which hand's search failed.
///
/// ```text
/// 6e4171: test dh,0x4 ; bit 10 (0x400) main-hand-only -> 0x1a
/// 6e417a: shr edx,0x17 ; else bit 24 (0x1000000) off-hand-only -> 0x1b
/// 6e4180: or dl,0x19 ; else -> 0x19
/// ```
///
/// All three render `ERR_SPELL_FAILED_EQUIPPED_ITEM_CLASS_S` — "Must have a %s equipped" — with
/// the item **subclass** name. `0x18` ("Must have the proper item equipped") is **not** from this
/// function: it belongs to the next rung, the ranged-slot check, and the disarm gate never touches
/// slot 2. `0x2f` is this function's ammo tail, not an equipped-item reason at all.
pub(crate) fn equipped_item_reason(d: &SpellDisplay) -> u8 {
if d.attributes_ex3 & ATTR_EX3_MAIN_HAND_ONLY != 0 {
0x1a
} else if d.attributes_ex3 & ATTR_EX3_OFF_HAND_ONLY != 0 {
0x1b
} else {
0x19
}
}
/// Leg 4's own test (`0x6e40e0`), shared with the spell tooltip's requirement line: does some
/// WORN item match `EquippedItemClass` + `EquippedItemSubClassMask`? `true` when the spell asks
/// for nothing (`class < 0`). An equipped item whose template hasn't streamed yet counts as a
@@ -77,19 +156,31 @@ pub(crate) fn equipped_item_fits(
items: &mut Items,
commands: &NetCommands,
) -> bool {
if d.equipped_item_class < 0 {
// The reference's four short-circuits, all answering "fits" without looking at a single slot
// (`0x6e40e0` @ `6e4103`–`6e4130`; decision 1925 correcting 1903, which shipped only the first
// of them):
//
// 1. the caster is not the active player — so a **pet** cast never takes this refusal;
// 2. `EquippedItemClass < 0` — the spell asks for nothing;
// 3. **`EquippedItemSubClassMask == 0`** — likewise, and this one is not the same as "every
// subclass matches": the reference answers fits with **no item required at all**, where
// treating it as a wildcard still demands something of the right class be worn. That was
// 1903's bug, and it made a mask-less requirement refusable on an empty slot;
// 4. `Targets & TARGET_FLAG_ITEM` — an item-targeting spell describes the CLICKED item with
// these columns, not the caster's gear, and a different validator (`0x495d60`) enforces
// them there.
//
// Leg 1 is ours by construction: this only ever runs for the local player.
if d.equipped_item_class < 0
|| d.equipped_item_subclass_mask == 0
|| d.targets & TARGET_FLAG_ITEM != 0
{
return true;
}
let class = d.equipped_item_class as u32;
// The **slot mask** first (decision 1903): `AttributesEx3` narrows the search to one hand for
// an ability that names one, and to everything otherwise.
let mut mask: u32 = if d.attributes_ex3 & ATTR_EX3_MAIN_HAND_ONLY != 0 {
1 << crate::items::EQUIPMENT_SLOT_MAINHAND
} else if d.attributes_ex3 & ATTR_EX3_OFF_HAND_ONLY != 0 {
1 << crate::items::EQUIPMENT_SLOT_OFFHAND
} else {
u32::MAX
};
let mut mask = hand_mask(d);
// …then the disarm ladder strips the hidden hand's bit out of it (decision 1863, its citation
// corrected by 1903: the reference does this at `0x5f0c69`/`0x5f0c91` with `visFlag = 1`
// probes and a mask edit, NOT by `GetWeapon` returning NULL — same ladder, same outcome, one
@@ -98,40 +189,70 @@ pub(crate) fn equipped_item_fits(
if let Some(hidden) = crate::items::disarmed_equipment_slot(store, items, commands) {
mask &= !(1u32 << hidden);
}
equipped_slots_match(store, mask, class, d.equipped_item_subclass_mask, |guid| {
let (entry, durability, max_durability) = items.object(guid).and_then(|o| {
Some((
o.object_entry()?,
o.item_durability(),
o.item_max_durability(),
))
})?;
let t = items.template(entry, guid, commands)?;
Some(WornItem {
class: t.class,
subclass: t.subclass,
flags: t.flags,
durability,
max_durability,
})
})
}
/// One worn item, as the equipped-item search reads it.
pub(crate) struct WornItem {
pub(crate) class: u32,
pub(crate) subclass: u32,
/// The **template** flags — `ITEM_FLAG_DEPRECATED` is the reject.
pub(crate) flags: u32,
/// The **instance** durability pair (`[item+0x114]+0xa0`/`+0xa4`), not the template's.
pub(crate) durability: Option<u32>,
pub(crate) max_durability: Option<u32>,
}
/// The search body, over a per-slot resolver the caller supplies — because two callers need it
/// with different borrows: the greying leg and the tooltip hold `&mut Items` and may ASK the
/// server on a miss, while the cast ladder's rung 7 runs inside a `&Items` borrow and must not
/// (decision 1925). `None` from the resolver is the shared benefit-of-the-doubt: an item whose
/// template has not landed counts as a match, never a refusal on missing data.
fn equipped_slots_match(
store: &ObjectStore,
mask: u32,
class: u32,
subclass_mask: u32,
mut worn: impl FnMut(u64) -> Option<WornItem>,
) -> bool {
(0..EQUIPMENT_SLOTS)
.filter(|slot| mask & (1u32 << slot) != 0)
.any(|slot| {
let Some(guid) = store.0.player_inv_slot(slot).filter(|&g| g != 0) else {
return false;
};
// Entry and the durability pair come off the INSTANCE in one borrow — the reference
// reads both from the CGItem (`[item+0x114]+0xa0`/`+0xa4`), not from the template —
// because the template lookup below needs `items` mutably.
let Some((entry, durability, max_durability)) = items.object(guid).and_then(|o| {
Some((
o.object_entry()?,
o.item_durability(),
o.item_max_durability(),
))
}) else {
return false;
};
let Some(t) = items.template(entry, guid, commands) else {
let Some(it) = worn(guid) else {
return true; // unresolved template: benefit of the doubt
};
// The reference's two rejects, applied before the class match: a DEPRECATED item and
// a genuinely BROKEN one (`MaxDurability > 0 && Durability == 0`) cannot satisfy a
// requirement. Note this is the one place durability DOES gate something — it drives
// no animation or model path anywhere (decision 1863).
if t.flags & ITEM_FLAG_DEPRECATED != 0 {
if it.flags & ITEM_FLAG_DEPRECATED != 0 {
return false;
}
if max_durability.is_some_and(|m| m > 0) && durability == Some(0) {
if it.max_durability.is_some_and(|m| m > 0) && it.durability == Some(0) {
return false;
}
t.class == class
&& (d.equipped_item_subclass_mask == 0
|| d.equipped_item_subclass_mask & (1 << t.subclass) != 0)
// A zero subclass mask never reaches here — it short-circuits above, as the
// reference does.
it.class == class && subclass_mask & (1 << it.subclass) != 0
})
}
@@ -334,7 +455,9 @@ mod tests {
// "Requires a melee weapon" — class 2, any subclass.
let needs_a_weapon = SpellDisplay {
equipped_item_class: 2,
equipped_item_subclass_mask: 0,
// Subclass 7 = Sword1H, the fixture's item. A **zero** mask would short-circuit the
// whole search to "fits" — the reference's third gate — so it cannot be the fixture.
equipped_item_subclass_mask: 1 << 7,
..Default::default()
};
let fits = |disarmed: bool, hands: &[(u16, u64)]| {
@@ -387,7 +510,7 @@ mod tests {
// A spell requiring a class-2 weapon, with whatever `AttributesEx3` is passed.
let spell = |ex3: u32| SpellDisplay {
equipped_item_class: 2,
equipped_item_subclass_mask: 0,
equipped_item_subclass_mask: 1 << 7, // Sword1H — a zero mask short-circuits to "fits"
attributes_ex3: ex3,
..Default::default()
};
@@ -486,6 +609,7 @@ mod tests {
equipped_item_fits(
&SpellDisplay {
equipped_item_class: 2,
equipped_item_subclass_mask: 1 << 7, // Sword1H
attributes_ex3: ex3,
..Default::default()
},
+1 -1
View File
@@ -179,7 +179,7 @@ impl Plugin for UiChatPlugin {
///
/// `shutdown_ui_state`'s own doc carves the reference's logout tail: `PLAYER_LEAVING_WORLD` →
/// `PLAYER_LOGOUT` → the saved files → **destroy the Lua state**. That last step is the one
/// [`crate::ui_script::IngameUiLoaded`] exists to stand in for; while it does, every window in the
/// `crate::ui_script::IngameUiLoaded` exists to stand in for; while it does, every window in the
/// VM keeps its contents across a character switch. The director saw it as the previous
/// character's `Joined Channel:` lines still sitting under the new character's — chat scrollback
/// is simply the most visible tenant of a VM that should have been rebuilt.
+67 -10
View File
@@ -185,6 +185,12 @@ pub(super) struct ChatOut<'w> {
/// same `seat` the wire arm calls, so the remote leg — the `partyN` token, the marker, the
/// pin holding while you walk — is exercisable solo.
ping: ResMut<'w, crate::minimap::MinimapPing>,
/// The red UIErrorsFrame line by GlobalStrings key — the emote-while-moving refusal is its one
/// tenant here (decision 1904). It rides this bundle rather than the system's own parameter
/// list because that list is at Bevy's arity limit; checked first against every other param,
/// since a resource reachable twice from one system is a `B0002` panic on the first live frame
/// (1903). Nothing else in this system touches `UiErrorKeys`.
ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
@@ -796,13 +802,33 @@ pub(super) fn drain_chat_input(
// A chat-only text emote (no Emotes.dbc row) has no EmoteFlags to test and no
// posture to set — it always sends.
let posture = emote_id.and_then(|id| emotes.as_deref()?.posture_state(id));
// GATE A (`CheckEmoteEligible` `0x47db40`): suppresses the anim AND the packet.
let eligible = match emotes.as_deref() {
// GATE A (`CheckEmoteEligible` `0x47db40`): suppresses the anim AND the packet —
// except its `0x4000` arm, which refuses out loud instead (decision 1904).
let gate = match emotes.as_deref() {
Some(e) => emote_id
.and_then(|id| e.emote_flags(id))
.is_none_or(|f| emote_send_eligible(f, stand_state, swimming)),
None => true,
.map_or(EmoteGate::Send, |f| {
emote_send_eligible(f, stand_state, swimming, flags)
}),
None => EmoteGate::Send,
};
// The `0x4000` arm's own half of the law, which lives in `DoEmote` (`0x5ef5d0`)
// and not in the eligibility check: the red line fires only while the caster is
// acting under its OWN control, so a feared or confused player emotes normally.
if gate == EmoteGate::Moving {
let controlled = self_store
.single()
.is_ok_and(|s| crate::player::self_controlled(s.0.unit_flags()));
if controlled {
debug!("chat: emote {text_id} refused — moving (ERR_NOEMOTEWHILERUNNING)");
chat_out
.ui_errors
.0
.push(crate::ui_action::UiError::key("ERR_NOEMOTEWHILERUNNING"));
continue;
}
}
let eligible = gate != EmoteGate::Suppressed;
// GATE B (`0x5ef5f3`): asleep, only a POSTURE emote gets through — which is how
// `/stand` (and `/sit`) is the way out of `/sleep`, while a `/wave` in bed does
// nothing at all.
@@ -1355,6 +1381,22 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) {
///
/// # The fifth flag, `0x4000` — NOT built, and the reason recorded here was WRONG
///
/// What `CheckEmoteEligible 0x47db40` decides about one emote — three outcomes, not two, which is
/// why this replaced a `bool` (decision 1904).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum EmoteGate {
/// Send it: packet, posture, animation.
Send,
/// **Silently** dropped — no packet, no animation, and no line. A seated `/bow` is this.
Suppressed,
/// The `0x4000` "requires standing still" arm tripped. The reference does NOT suppress here:
/// `CheckEmoteEligible` writes an out-param and `DoEmote` reads it at `0x5ef5d0`, raising
/// `ERR_NOEMOTEWHILERUNNING` and aborting **only when `IsSelfControlled 0x5fa550` is
/// non-zero** — so the toast fires for an ordinary moving player and a **feared** one emotes
/// away happily. That caster-state test is the caller's, exactly as it is the reference's.
Moving,
}
/// This doc used to say `0x4000` ("requires standing still") *"only sets an out param the client
/// acts on while fear/confuse-controlled, which benilla doesn't model"*. A §5 trio carve of the
/// neighbouring stand-state gate re-read the leg and **inverted that polarity**
@@ -1378,25 +1420,40 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) {
/// to do with B155 and could never have covered it (`super::super::tests::
/// the_posture_emotes_carry_no_swim_suppression_flag` is the data half of that same negative). The
/// posture path's water refusal lives in [`crate::player::state`]'s `stand_state_refused`.
pub(super) fn emote_send_eligible(emote_flags: u32, stand_state: u8, swimming: bool) -> bool {
pub(super) fn emote_send_eligible(
emote_flags: u32,
stand_state: u8,
swimming: bool,
move_flags: u32,
) -> EmoteGate {
// `0x0400`: unconditional suppress (client `0x47db58`).
if emote_flags & 0x0400 != 0 {
return false;
return EmoteGate::Suppressed;
}
// `0x0001` + non-zero stand-state: "requires STAND" (client `0x47db65`..`0x47db74`).
if emote_flags & 0x0001 != 0 && stand_state != 0 {
return false;
return EmoteGate::Suppressed;
}
// `0x0080` while swimming (client `0x47db76`..`0x47db7d`).
if emote_flags & 0x0080 != 0 && swimming {
return false;
return EmoteGate::Suppressed;
}
// `0x0200` ABSENT at SLEEP(3)/DEAD(7): the bit means "allowed while asleep/dead" (client
// `0x47db8e`..`0x47db9f`).
if emote_flags & 0x0200 == 0 && matches!(stand_state, 3 | 7) {
return false;
return EmoteGate::Suppressed;
}
true
// `0x4000` "requires standing still" (client `0x47dbab`), tested against the live CMovement
// word at `0x47dbb3` — the reference's own `0x20ff`, which is exactly
// [`crate::creature_anim::move_flags::INTEGRATED`]: the four direction bits, the two turn
// bits, the two pitch bits and FALLING. Pointedly **not** SWIMMING.
//
// This arm does not suppress: it writes `*out = 1`, and DoEmote turns that into a red line
// (or not) on a caster-state test the caller owns — see [`EmoteGate::Moving`].
if emote_flags & 0x4000 != 0 && move_flags & crate::creature_anim::move_flags::INTEGRATED != 0 {
return EmoteGate::Moving;
}
EmoteGate::Send
}
/// Turn an addon's `SendChatMessage` calls into sends (decision 1199).
+104 -16
View File
@@ -2,7 +2,7 @@ use crate::net::ChatKind;
use super::event::{default_color, ChatEvent, ChatEventKind as K};
use super::frames::compose;
use super::input::{emote_send_eligible, emote_target, ParsedChat};
use super::input::{emote_send_eligible, emote_target, EmoteGate, ParsedChat};
/// A player-line event (the wire bridge's output shape) — sender resolved, optional flag.
fn ev(kind: K, text: &str, sender: &str) -> ChatEvent {
@@ -1474,6 +1474,80 @@ fn real_alias_table_resolves_the_shipped_commands() {
);
}
/// **The `0x4000` "requires standing still" arm** (decision 1904) — the one gate arm that does not
/// suppress silently. It reports [`EmoteGate::Moving`] and the CALLER turns that into a red
/// `ERR_NOEMOTEWHILERUNNING`, but only while the caster is self-controlled.
///
/// The mask is the reference's own `0x20ff` — [`move_flags::INTEGRATED`] — and what it leaves out
/// matters as much as what it holds: **SWIMMING is not in it**, so a swimmer standing still in the
/// water emotes fine. That negative is why this arm has nothing to do with the swim suppression
/// tested above.
#[test]
fn the_standing_still_arm_reports_moving_and_ignores_swimming() {
use super::input::EmoteGate;
use crate::creature_anim::move_flags;
const STILL: u32 = 0x4000;
// Not moving: it sends.
assert_eq!(emote_send_eligible(STILL, 0, false, 0), EmoteGate::Send);
// Any INTEGRATED bit trips it — a direction, a keyboard turn, or a fall.
for f in [
move_flags::FORWARD,
move_flags::BACKWARD,
move_flags::STRAFE_LEFT,
move_flags::TURN_RIGHT,
move_flags::FALLING,
] {
assert_eq!(
emote_send_eligible(STILL, 0, false, f),
EmoteGate::Moving,
"move flag {f:#x}"
);
}
// SWIMMING is NOT in `0x20ff`: a still swimmer is still.
assert_eq!(
emote_send_eligible(STILL, 0, false, move_flags::SWIMMING),
EmoteGate::Send,
"the mover-integration mask carries no SWIMMING"
);
// WALK_MODE and LEVITATING are mode bits, not motion.
assert_eq!(
emote_send_eligible(STILL, 0, false, move_flags::WALK_MODE),
EmoteGate::Send
);
// Without the flag, movement is irrelevant.
assert_eq!(
emote_send_eligible(0, 0, false, move_flags::FORWARD),
EmoteGate::Send
);
// An unconditionally-suppressed emote never reaches this arm — it stays SILENT, which is why
// only 20 of the 33 rows carrying `0x4000` can actually raise the line.
assert_eq!(
emote_send_eligible(0x4400, 0, false, move_flags::FORWARD),
EmoteGate::Suppressed
);
}
/// **`IsSelfControlled`'s polarity** (`0x5fa550`, decision 1904) — the half that decides whether
/// the moving refusal is heard. It is `true` for an ordinary player and `false` while confused,
/// fleeing or move-disabled, so the red line fires in the NORMAL case and a feared player emotes
/// away. STUNNED is deliberately absent from the mask.
#[test]
fn self_controlled_is_true_for_an_ordinary_player() {
use crate::player::self_controlled;
assert!(self_controlled(0));
assert!(self_controlled(1 << 3), "an ordinary PvP-attackable player");
assert!(!self_controlled(0x0040_0000), "confused");
assert!(!self_controlled(0x0080_0000), "fleeing");
assert!(!self_controlled(0x0000_0004), "move-disabled");
assert!(
self_controlled(0x0004_0000),
"STUNNED is not in the 0xc00004 mask — it has its own gate"
);
}
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
// from wow-re `emote-posture-gate.md` §3, real `Emotes.dbc` `EmoteFlags` values.
const BOW: u32 = 0x4801;
@@ -1486,41 +1560,54 @@ const LAUGH: u32 = 0x0980;
#[test]
fn seated_stand_required_emotes_are_suppressed() {
let _data = benilla_formats::wow_data_or_skip!();
assert!(!emote_send_eligible(BOW, 1, false)); // 0x4801 has 0x1 (requires STAND)
assert!(!emote_send_eligible(RUDE, 1, false));
assert_eq!(emote_send_eligible(BOW, 1, false, 0), EmoteGate::Suppressed); // 0x4801 has 0x1 (requires STAND)
assert_eq!(
emote_send_eligible(RUDE, 1, false, 0),
EmoteGate::Suppressed
);
}
#[test]
fn seated_non_stand_emotes_pass() {
let _data = benilla_formats::wow_data_or_skip!();
assert!(emote_send_eligible(APPLAUD, 1, false));
assert!(emote_send_eligible(CHEER, 1, false));
assert!(emote_send_eligible(LAUGH, 1, false));
assert!(emote_send_eligible(SALUTE, 1, false));
assert_eq!(emote_send_eligible(APPLAUD, 1, false, 0), EmoteGate::Send);
assert_eq!(emote_send_eligible(CHEER, 1, false, 0), EmoteGate::Send);
assert_eq!(emote_send_eligible(LAUGH, 1, false, 0), EmoteGate::Send);
assert_eq!(emote_send_eligible(SALUTE, 1, false, 0), EmoteGate::Send);
}
#[test]
fn swimming_suppresses_only_the_0x80_emotes() {
let _data = benilla_formats::wow_data_or_skip!();
assert!(!emote_send_eligible(LAUGH, 0, true)); // 0x0980 has 0x80
assert!(emote_send_eligible(CHEER, 0, true));
assert_eq!(
emote_send_eligible(LAUGH, 0, true, 0),
EmoteGate::Suppressed
); // 0x0980 has 0x80
assert_eq!(emote_send_eligible(CHEER, 0, true, 0), EmoteGate::Send);
}
#[test]
fn standing_and_dry_everyone_is_eligible() {
let _data = benilla_formats::wow_data_or_skip!();
for flags in [BOW, RUDE, APPLAUD, CHEER, SALUTE, LAUGH] {
assert!(emote_send_eligible(flags, 0, false), "flags {flags:#x}");
assert_eq!(
emote_send_eligible(flags, 0, false, 0),
EmoteGate::Send,
"flags {flags:#x}"
);
}
}
#[test]
fn unconditional_and_sleep_dead_rules() {
let _data = benilla_formats::wow_data_or_skip!();
assert!(!emote_send_eligible(0x0400, 0, false)); // unconditional suppress
assert!(!emote_send_eligible(0, 3, false)); // SLEEP without the allow bit
assert!(!emote_send_eligible(0, 7, false)); // DEAD without the allow bit
assert!(emote_send_eligible(0x0200, 3, false)); // "allowed while asleep/dead"
assert_eq!(
emote_send_eligible(0x0400, 0, false, 0),
EmoteGate::Suppressed
); // unconditional suppress
assert_eq!(emote_send_eligible(0, 3, false, 0), EmoteGate::Suppressed); // SLEEP without the allow bit
assert_eq!(emote_send_eligible(0, 7, false, 0), EmoteGate::Suppressed); // DEAD without the allow bit
assert_eq!(emote_send_eligible(0x0200, 3, false, 0), EmoteGate::Send); // "allowed while asleep/dead"
}
// ── The open-the-box law, shared by the ENTER key and an addon's ChatFrame_OpenChat ──────────
@@ -2318,8 +2405,9 @@ fn the_posture_emotes_carry_no_swim_suppression_flag() {
}
// …so the emote gate lets it through mid-swim. (`stand_state` here is the performer's
// CURRENT state; a standing swimmer pressing `/sit` is the reported case.)
assert!(
super::input::emote_send_eligible(flags, 0, true),
assert_eq!(
super::input::emote_send_eligible(flags, 0, true, 0),
super::input::EmoteGate::Send,
"posture emote {id} passes the emote gate while swimming"
);
}
+1 -1
View File
@@ -189,7 +189,7 @@ pub(super) fn drain_bag_autostores(
/// Drain the inventory-slot ids `UseInventoryItem` queued (decision 0208 phase 1b: the doll
/// slot's right-click) and route the equipped position (bag 255 plus the 0-based wire slot —
/// `HandleUseItemOpcode` takes equipped positions the same as bag ones, vmangos `ItemHandler.cpp`)
/// through the shared use fork ([`super::item_use_command`]): the reference's doll click lands in
/// through the shared use fork (`super::item_use_command`): the reference's doll click lands in
/// the same `CGItem::Use` a bag click does (`0x4c7af0`), quest fork included — one of the five
/// equippable quest-starters, worn and right-clicked, offers its quest instead of casting nothing.
/// Ids outside 1..=19 (ammo, the bag icons) are a no-op — the engine's own queue never receives
+1 -1
View File
@@ -44,7 +44,7 @@ static UI: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR
/// The text of one shipped UI file, by path relative to `assets/ui` — `None` if we do not ship it.
///
/// `req` may arrive in Blizzard's backslash form and may name a directory it does not have; the
/// basename fallback is [`super::load_ui_files`]'s and lives at the call site, not here, because
/// basename fallback is `super::load_ui_files`'s and lives at the call site, not here, because
/// it is a property of FrameXML references rather than of the content store.
pub(super) fn read(req: &str) -> Option<String> {
// 1 · the source tree, dev builds only — so editing a FrameXML file needs no recompile.
+8 -133
View File
@@ -99,65 +99,19 @@ impl TaxiState {
/// A flight master's answered node status (`SMSG_TAXINODE_STATUS`, upserted by the net bridge):
/// `known = false` (an undiscovered nearest node) shows the green `TalkToMeGreen` overhead icon —
/// the client's `0x5ecdd0` handler → `0x607480` marker swap (resource table `0xc4d9d8` index 4),
/// byte-verified in the 0497 §5. Read by [`crate::quest_markers`], which owns the shared overhead
/// attach slot both this and the questgiver markers ride.
/// byte-verified in the 0497 §5.
///
/// **The query and the teardown are [`crate::quest_markers::query`]'s, not this module's**
/// (decision 1918). `0x5eb170` — the only `CMSG_TAXINODE_STATUS_QUERY` sender in the image — has
/// exactly one live caller, `0x607380` @`0x6073e8`, which is the same per-unit function that issues
/// the questgiver query and which tears the shared marker slot down before either. The green `!`
/// and the gold `!` are the *same* `unit+0xb2c`, so they cannot have separate lifetimes; this
/// component is the fact, and its owner is the sweep.
#[derive(Component, Clone, Copy)]
pub(crate) struct FlightMasterStatus {
pub(crate) known: bool,
}
/// Ask each streamed flight master for its node status (`CMSG_TAXINODE_STATUS_QUERY`) — the
/// client's sender `0x5eb170`, triggered on unit refresh and on mouseover, gated on
/// `UNIT_NPC_FLAG_FLIGHTMASTER` (bit 3) and a non-hostile reaction (`0x6061e0 > 1`). Our shape:
/// once per FM while it stays streamed (the `asked` set prunes to the live index, so a despawn +
/// re-stream re-asks — the refresh trigger), and a re-ask when the hover lands on one (the
/// mouseover trigger; the learn push — vmangos pairing `SMSG_NEW_TAXI_PATH` with a fresh status —
/// clears the icon without it, this just keeps the edge honest).
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // a Bevy system's full input set
fn query_fm_statuses(
self_q: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
units: Query<
(&crate::net::Guid, &crate::net::ObjectStore),
(With<crate::net::NetEntity>, Without<crate::net::SelfPlayer>),
>,
index: Res<crate::net::GuidIndex>,
factions: Option<Res<crate::target::Factions>>,
reputations: Res<crate::net::Reputations>,
hovered: Res<crate::target::Hovered>,
commands: Res<NetCommands>,
mut asked: Local<std::collections::HashSet<u64>>,
mut last_hover: Local<Option<u64>>,
) {
/// `UNIT_NPC_FLAG_FLIGHTMASTER` (bit 3) — the cursor classifier's bit.
const NPC_FLAG_FLIGHTMASTER: u32 = 0x8;
let self_store = self_q.iter().next();
asked.retain(|g| index.0.contains_key(g));
// The mouseover re-ask: on the hover edge onto an already-asked FM, forget it so the loop
// below re-sends this frame.
let hover_edge = (hovered.guid != *last_hover)
.then_some(hovered.guid)
.flatten();
*last_hover = hovered.guid;
if let Some(g) = hover_edge {
asked.remove(&g);
}
for (guid, obj) in &units {
if obj.0.unit_npc_flags() & NPC_FLAG_FLIGHTMASTER == 0 {
continue;
}
if crate::target::ring_reaction(factions.as_deref(), &reputations, Some(obj), self_store)
<= 1
{
continue; // hostile — the client's reaction gate skips the query
}
if asked.insert(guid.0) {
let _ = commands
.0
.send(ClientCommand::TaxiNodeStatusQuery { guid: guid.0 });
}
}
}
/// The taxi window is an NPC session: the standardized range guard ([`crate::ui_session`])
/// client-side-closes it — the exact clear the close button does — when the player walks out of
/// the flight master's service range or it despawns.
@@ -384,7 +338,6 @@ impl Plugin for UiTaxiPlugin {
close_npc_session_out_of_range::<TaxiState>.before(feed_taxi),
feed_taxi.before(UiInput),
drain_taxi.after(UiInput),
query_fm_statuses,
),
);
}
@@ -421,82 +374,4 @@ mod tests {
assert_eq!(state.reply, None);
assert!(!state.discovered);
}
/// The flight-master status ask: once per streamed FM, re-asked on a re-stream (the client's
/// refresh trigger) and on the hover edge (the mouseover trigger). No `Factions` catalog in
/// the app → reaction falls through to neutral (3 > 1), so the gate is the NPC flag alone.
#[test]
fn fm_status_is_asked_once_per_stream_and_reasked_on_hover() {
use std::collections::HashMap;
use benilla_protocol::messages::ObjectFields;
use benilla_protocol::EntityKind;
use crate::net::{Guid, GuidIndex, NetEntity, ObjectStore, SelfPlayer};
let mut app = App::new();
app.add_systems(Update, query_fm_statuses);
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::target::Hovered>();
app.world_mut()
.spawn((ObjectStore(ObjectFields::default()), SelfPlayer));
// UNIT_NPC_FLAGS (field 147) = 0x8 flightmaster; a plain vendor (0x4) must not query.
let fm = app
.world_mut()
.spawn((
Guid(42),
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
ObjectStore(ObjectFields::from_pairs(&[(147, 0x8)])),
))
.id();
app.world_mut().spawn((
Guid(43),
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
ObjectStore(ObjectFields::from_pairs(&[(147, 0x4)])),
));
app.insert_resource(GuidIndex([(42, fm)].into_iter().collect()));
let drain = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
let mut asked = Vec::new();
while let Ok(c) = rx.try_recv() {
if let ClientCommand::TaxiNodeStatusQuery { guid } = c {
asked.push(guid);
}
}
asked
};
app.update();
assert_eq!(
drain(&rx),
[42],
"one query for the FM, none for the vendor"
);
app.update();
assert_eq!(drain(&rx), [] as [u64; 0], "asked once while streamed");
// Despawn from the index and back — the refresh trigger re-asks.
app.insert_resource(GuidIndex(HashMap::default()));
app.update();
app.insert_resource(GuidIndex([(42, fm)].into_iter().collect()));
app.update();
assert_eq!(drain(&rx), [42], "a re-stream re-asks");
// The hover edge onto the FM re-asks; holding the hover doesn't repeat.
app.world_mut()
.resource_mut::<crate::target::Hovered>()
.guid = Some(42);
app.update();
app.update();
assert_eq!(drain(&rx), [42], "the mouseover edge re-asks exactly once");
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ fn upload_glyph_cells(
/// `WOW_GLYPH_CACHE=1`: one line a second of what the cache holds.
///
/// It exists because [`super::pack::MAX_PAGES`] and [`super::pack::PAGE_SIZE`] are the two numbers
/// It exists because `super::pack::MAX_PAGES` and `super::pack::PAGE_SIZE` are the two numbers
/// this design can get wrong quietly — too small and a session resets on a loading screen, too
/// large and we hold VRAM nobody reads. Both are cheap to re-choose *from a measurement* and
/// expensive to re-choose from an argument, so the measurement ships with them.
+2 -2
View File
@@ -383,10 +383,10 @@ fn display_session() -> String {
""
};
let deck = if set("SteamDeck") { ", steamdeck" } else { "" };
return format!(
format!(
" [{backend}{nested}{deck}, XDG_SESSION_TYPE={}]",
std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "unset".into()),
);
)
}
#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
String::new()
+2 -1
View File
@@ -817,7 +817,8 @@ mod tests {
fn registered_dbc_schemas_dump_the_shipped_tables() {
let data = crate::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
let out = std::env::temp_dir().join("benilla-schema-registry-test.csv");
let out =
std::env::temp_dir().join(format!("benilla-schema-reg-{}.csv", std::process::id()));
for table in [
"CreatureDisplayInfo",
"CreatureDisplayInfoExtra",
+6 -2
View File
@@ -213,7 +213,11 @@ fn decodes_a_blp_icon_to_png() {
.read_file("Interface/Icons/Spell_Holy_ArcaneIntellect.blp")
.expect("read spell icon BLP");
let out = std::env::temp_dir().join("benilla_formats_test_icon.png");
// Keyed by pid, like every other temp path in the workspace: eight pool slots run
// `cargo test --workspace` concurrently, and a FIXED shared name means one run's
// cleanup deletes the file another is about to read. That is not hypothetical — the
// taxi twin below reddened a land gate exactly that way (decision 1918).
let out = std::env::temp_dir().join(format!("benilla-fmt-icon-{}.png", std::process::id()));
let (w, h) = benilla_formats::blp_to_png(&bytes, &out).expect("decode BLP -> PNG");
assert_eq!((w, h), (64, 64), "spell icons are 64x64");
@@ -231,7 +235,7 @@ fn dumps_taxinodes_to_csv() {
.read_file("DBFilesClient/TaxiNodes.dbc")
.expect("read TaxiNodes.dbc");
let out = std::env::temp_dir().join("benilla_formats_test_taxi.csv");
let out = std::env::temp_dir().join(format!("benilla-fmt-taxi-{}.csv", std::process::id()));
let (records, fields) =
benilla_formats::dbc_to_csv(&bytes, "TaxiNodes.dbc", &out).expect("dbc->csv");
@@ -573,7 +573,7 @@ const TARGET_FLAG_UNIT: u16 = 0x0002;
/// a ground-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_at_dest`]).
const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040;
/// `TARGET_FLAG_ITEM` — a bound *item* target, the same bit and the same packed guid an
/// item-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_on_item`]). One block
/// item-targeted `CMSG_CAST_SPELL` writes (`super::spells::cast_spell_on_item`). One block
/// builder, two opcodes (decision 0923).
const TARGET_FLAG_ITEM: u16 = 0x0010;
@@ -22,7 +22,7 @@ pub(super) const MOVEMENT_FLAG_SPLINE_ELEVATION: u32 = 0x400_0000;
/// `flags`/`position`/`orientation`/`timestamp`/`fall_time`, the **transport pose** ([`TransportPose`],
/// present iff `MOVEFLAG_ON_TRANSPORT` — this is how a boarded rider's `MSG_MOVE_*` heartbeat carries
/// its local frame, decision 0438 "Riding is the mover's platform frame"), the **swim pitch**
/// ([`Self::pitch`], present iff `MOVEFLAG_SWIMMING`), and the **jump tail** ([`JumpInfo`], so an
/// (`Self::pitch`, present iff `MOVEFLAG_SWIMMING`), and the **jump tail** ([`JumpInfo`], so an
/// observer can replay a jump arc); the spline-elevation tail is parsed to stay aligned but discarded.
///
/// VERIFIED byte-for-byte against vmangos `MovementInfo::Read` (build 1.12.1): note 1.12 has **no**
@@ -265,7 +265,7 @@ pub struct TransportPose {
/// (decision 0438 phase 2). The transport, swim-pitch, and jump tails are conditional
/// outbound, gated on their flags. Inbound, every conditional tail is parsed (see
/// [`read_movement_info`]) — the transport pose into [`Self::transport`], the swim pitch into
/// [`Self::pitch`], the jump tail into [`Self::jump`], and the spline-elevation float to stay aligned
/// `Self::pitch`, the jump tail into [`Self::jump`], and the spline-elevation float to stay aligned
/// only (no consumer needs it).
pub struct MovementInfo {
pub flags: u32,
@@ -586,7 +586,7 @@ pub struct PlayerSkillSlot {
/// one of these per object and [`Self::merge`]s each `Values` delta into it; the codec itself
/// stays stateless (decision 0006).
///
/// **Absent-field semantics** hinge on [`Self::descriptor_end`]: a CREATE block is a *complete*
/// **Absent-field semantics** hinge on `Self::descriptor_end`: a CREATE block is a *complete*
/// snapshot — the server masks in only non-zero fields (vmangos `Object::_SetCreateBits`: bit set
/// iff `value != 0`), and the real client reads it into a zero-initialized descriptor buffer — so
/// on a create-seeded store an absent field reads `Some(0)`, the truth. A bare `Values` delta
@@ -45,7 +45,7 @@ impl WorldWriter {
/// Open an item by bag position (`CMSG_OPEN_ITEM`, layout in [`messages::open_item`]) — crack
/// the clam, empty the picked lockbox, unwrap the gift. The right-click fork for an
/// [`crate::ItemInfo::openable`] item; the server answers with `SMSG_LOOT_RESPONSE` on the
/// `crate::ItemInfo::openable` item; the server answers with `SMSG_LOOT_RESPONSE` on the
/// item's **own** guid (so the loot window opens over a thing in the bag), or an equip error
/// on a refusal (still locked, dead, flying).
pub fn open_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
+1 -1
View File
@@ -50,7 +50,7 @@ impl super::UiScript {
}
/// The dressing-room pane's bake yaw in radians (the ref's `Model:SetRotation`), read by the
/// app onto the `"dressup"` booth slot each frame — the twin of [`Self::paperdoll_yaw`].
/// app onto the `"dressup"` booth slot each frame — the twin of `Self::paperdoll_yaw`.
pub fn dressup_yaw(&self) -> f32 {
self.model_ref().dressup_yaw
}
+2 -2
View File
@@ -20,7 +20,7 @@
//!
//! With the answer 0, that dropdown sized itself to 40px and seven character names drew outside
//! their own frame. The reference's own `SmallMoneyFrame` does the same thing (`MoneyFrame.lua`
//! l.202), which is why [`UiScript::set_digit_advances`](super::UiScript::set_digit_advances)
//! l.202), which is why `UiScript::set_digit_advances`
//! existed: a digits-only stand-in for exactly this, built because the general answer was missing.
//! This is the general answer, and it retires that special case.
//!
@@ -106,7 +106,7 @@ impl super::UiScript {
///
/// Call again to replace it — which the host must do whenever its **raster environment**
/// changes (window resize, `uiScale`), for the same reason
/// [`UiScript::forget_text_metrics`](super::UiScript::forget_text_metrics) exists: glyph
/// `UiScript::forget_text_metrics` exists: glyph
/// advances step to whole *physical* pixels, so a measurer built under one seam does not
/// answer for another.
pub fn set_text_measurer(&mut self, measurer: Box<dyn TextMeasure>) {
+1 -1
View File
@@ -427,7 +427,7 @@ pub(crate) struct Model {
pub(crate) target_nearest_friend_requests: Vec<bool>,
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
/// [`Self::target_requests`], which takes unit tokens. The app runs the shared by-name
/// `Self::target_requests`, which takes unit tokens. The app runs the shared by-name
/// resolver (`crate::target::by_name`, decision 0886) and commits the selection ([`unit`]).
pub(crate) target_by_name_requests: Vec<(String, bool)>,
/// Set when `ClearTarget()` fired with a live target — the ESC chain's LAST leg
+103 -6
View File
@@ -444,14 +444,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetSpellTabInfo(i) -> name, texture, offset, numSpells (the Era flat tuple); 1-based `i`,
// out of range -> a single nil (GetMerchantItemInfo's own out-of-range shape).
// GetSpellTabInfo(i) -> name, texture, offset, numSpells. **FOUR values on every path that
// returns** — `0x4b3ce0` has two live `ret`s and both `mov eax,4`; the `eax=0` one at `0x4b3d0d`
// is dead code after `luaL_error` longjmps. Byte-carved by a wow-re cross-check (decision 1931,
// `system/ui/scratch/spelltabinfo-return-contract.md`).
//
// **OUT OF RANGE IS `nil, nil, 0, 0` — LITERAL zeros, not a single nil.** This comment used to say
// "out of range -> a single nil (GetMerchantItemInfo's own out-of-range shape)", i.e. one
// binding's shape copied onto another on the assumption that they generalise. They do not
// (1919 proved it for GetSkillLineInfo; this is the same bug one verb over). Leg `0x4b3e12`
// pushes nil, nil, then `push 0; push 0; lua_pushnumber` twice — `0x6f3810` copies its two
// stack dwords VERBATIM, so that is exactly +0.0, not a stale offset and nothing off the array
// base. Index 0, a negative, past-the-count and the empty/no-player registry all reach it via
// ONE unsigned branch (`0x4b3d26 dec ebx / cmp ebx,eax / jae`).
//
// Why the zeros are load-bearing rather than cosmetic: `SpellBookFrame_OnLoad` runs
// `SpellBookSkillLineTab_OnClick(1)` -> `GetSpellTabInfo(1)` UNCONDITIONALLY at load, and
// `SpellBookFrame_Update` can pass `SpellBookFrame:GetID()`, which is 0. The stock file then
// does `id > (offset + numSpells)` unguarded (l.295) — a nil there raises, and `(0+0)` makes it
// true, so the reference HIDES EVERY BUTTON ON THE PAGE. That is the behaviour to match.
//
// **The argument RAISES when absent or not number-coercible** — `luaL_error(L, "Usage:
// GetSpellTabInfo(index)")`, a numeric string accepted. [`number_arg`] is that exact shape
// (coerce, truncate toward zero, raise the usage string), and the decrement happens AFTER it.
// Do NOT share `GetSpellName`'s marshaller: `0x4b3ec0` does `fsub 1.0` BEFORE truncating, and
// the two disagree below 1 (`0.5` is out-of-range here and index 0 there).
//
// Slot 1 is also nil on an IN-RANGE tab whose skillLineId has no DBC row (`0x4b3dbd`), and
// `(string?, nil, num, num)` is live in shipped data (SkillLine.dbc 733/753/754 have
// spellIconID 0) — both already the shape the in-range arm below produces.
g.set(
"GetSpellTabInfo",
lua.create_function(|lua, i: usize| {
lua.create_function(|lua, i: Value| {
let i = super::binding_abi::number_arg(lua, i, "Usage: GetSpellTabInfo(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(tab) = i.checked_sub(1).and_then(|n| model.spellbook.tabs.get(n)) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
// Truncate (done), THEN decrement, then the unsigned compare: a negative or zero
// index lands below 0 and `usize::try_from` refuses it — the `jae` leg.
let tab = usize::try_from(i64::from(i) - 1)
.ok()
.and_then(|n| model.spellbook.tabs.get(n));
let Some(tab) = tab else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Integer(0),
]));
};
let texture = match &tab.texture {
Some(t) => Value::String(lua.create_string(t)?),
@@ -929,7 +967,11 @@ mod tests {
fn tab_info_shapes_and_book_id_offsets() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 0);
assert!(s.eval::<bool>("return GetSpellTabInfo(1) == nil").unwrap());
// `== nil` reads true either way — Lua compares only the first value. The ARITY is the
// load-bearing half and lives in `out_of_range_answers_four_values` below (1931).
assert!(s
.eval::<bool>("return (GetSpellTabInfo(1)) == nil")
.unwrap());
s.set_spellbook(book());
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 2);
@@ -1485,4 +1527,59 @@ mod tests {
1
);
}
/// **Out of range answers `nil, nil, 0, 0` — four values, the last two NUMBERS** (1931). The
/// count is the assertion: `GetSpellTabInfo(0) == nil` is true either way, which is exactly how
/// the old one-nil shape passed. Stock `SpellBookFrame.lua:295` does `id > (offset + numSpells)`
/// unguarded straight off `SpellBookFrame_OnLoad`, so slots 3+4 nil is a raise on load, and
/// `(0+0)` is what makes the reference hide every button — the behaviour this pins.
#[test]
fn out_of_range_answers_four_values() {
let s = UiScript::new().unwrap();
for idx in ["0", "1", "99", "-1", "0.5"] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', GetSpellTabInfo({idx}))"))
.unwrap(),
4,
"GetSpellTabInfo({idx}) must answer four values"
);
assert_eq!(
s.eval::<i64>(&format!(
"local _,_,o,n = GetSpellTabInfo({idx}) return o + n"
))
.unwrap(),
0,
"GetSpellTabInfo({idx}) slots 3+4 must be NUMBERS summing to 0"
);
assert!(s
.eval::<bool>(&format!("return (GetSpellTabInfo({idx})) == nil"))
.unwrap());
}
}
/// The argument is shape A (1931): absent or non-numeric RAISES the reference's own usage
/// string; a numeric STRING is coerced; a negative is truncated and falls to the fallback
/// rather than raising. `-1` and `0.5` are covered by the test above.
#[test]
fn tab_index_raises_when_absent_and_coerces_a_numeric_string() {
let mut s = UiScript::new().unwrap();
for bad in [
"GetSpellTabInfo()",
"GetSpellTabInfo(nil)",
"GetSpellTabInfo({})",
"GetSpellTabInfo('abc')",
] {
let err = s.run(bad).expect_err(bad);
assert!(
format!("{err}").contains("Usage: GetSpellTabInfo(index)"),
"{bad}: got {err}"
);
}
s.set_spellbook(book());
// "1" coerces exactly as 1 does — same name comes back.
assert_eq!(
s.eval::<String>("return (GetSpellTabInfo('1'))").unwrap(),
s.eval::<String>("return (GetSpellTabInfo(1))").unwrap()
);
}
}
+1 -1
View File
@@ -448,7 +448,7 @@ impl super::UiScript {
/// one GOLD line (the reference's engine SetText gold; a cross-interior dot renders FAINT
/// gold — the byte law's `|cffb0b0b0` wrap modulating the gold base, director-matched),
/// seated centred ABOVE the cursor: the tooltip's BOTTOM at the given UI-space point. The
/// plate FOLLOWS the pointer — [`Self::minimap_tooltip_move`] re-seats it as the cursor
/// plate FOLLOWS the pointer — `Self::minimap_tooltip_move` re-seats it as the cursor
/// drifts within one blip. Same world-owned fade lifecycle as the mouseover tooltip:
/// hover loss arms [`Self::world_tooltip_fade`].
pub fn minimap_tooltip(&mut self, text: &str, ui_x: f32, ui_y: f32, grey: bool) -> bool {
+1 -1
View File
@@ -453,7 +453,7 @@ impl WidgetArena {
}
/// `EnableKeyboard` (`0x776f90`) — kind-0/kind-1 bucket membership. Stored and answered; the
/// key path is not gated on it yet (see [`crate::widget::WidgetState::keyboard_enabled`]).
/// key path is not gated on it yet (see `crate::widget::WidgetState::keyboard_enabled`).
pub fn set_keyboard_enabled(&mut self, h: FrameHandle, enabled: bool) {
if let Some(f) = self.frame_mut(h) {
f.keyboard_enabled = enabled;
+1 -1
View File
@@ -178,7 +178,7 @@ pub struct ArtScopeState {
/// Eviction radius in yards; `0` ⇒ never evict. Zero until [`configure_art_scope`] runs, so
/// nothing can be dropped before the streamer's reach is known.
radius: f32,
/// The view focus, wow coords ([`crate::terrain_stream::view_focus`] — the same ladder the
/// The view focus, wow coords (`crate::terrain_stream::view_focus` — the same ladder the
/// streamer and the WDL ring use). `None` when there is no focus at all (no avatar, no camera),
/// which suspends both stamping and sweeping.
focus: Option<[f32; 3]>,