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:
Generated
+71
-4
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
///
|
||||
|
||||
@@ -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(¬ices) 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)
|
||||
}
|
||||
|
||||
@@ -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, ¶m)
|
||||
};
|
||||
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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]>,
|
||||
|
||||
Reference in New Issue
Block a user