the login theme rides the loading screen, and the mix gets a limiter
- the login theme no longer cuts off the instant the loading screen appears: it plays through the whole load and hands over on a 3 s fade once the world is ready, as the reference does — the stop sat on the same edge that raised the cover, and the reference never calls its music-stop on the enter-world path at all
- the mix had no headroom: every WoW sound is mastered to full scale, so five party-wide Fortitudes in one frame asked for 5× and clipped hard ("dirty, like a speaker breaking") — a stereo-linked look-ahead limiter now holds the output, a meter reports the peak the mix asked for, `SoundOutputLimiter 0` A/Bs it, and the 3D voice ceiling is named at 512 instead of inherited from the backend at 128
- a shaman's Rockbiter / Flametongue / Frostbrand / Windfury Weapon casts straight onto the equipped weapon on the press — no item cursor, no click — the reference's own `Attributes & 0x200` read; stones, oils and poisons still raise the cursor
- the nameplate category's player arm is pinned to the bytes (CanCooperate is faction-group-mask equality) and `/reaction` prints the one number it turns on: a GM-mode character plates as an enemy on a test realm because faction 35's mask is zero, exactly as 1.12 does
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,11 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
|
||||
// `"1"` would ship audio the real client has never actually produced (bug B236).
|
||||
// `SoundConfig::reverb` carries the evidence.
|
||||
("SoundReverb", "0"),
|
||||
// The output limiter (1551) — benilla's own, not a 1.12 CVar. The reference needs no such DSP
|
||||
// (its mix is FMOD 3's and its headroom lives in the SFX-bus auto-duck); benilla sums into f32
|
||||
// behind a hard clamp, and every WoW SFX is mastered to full scale, so two overlapping kits
|
||||
// clip. Registered so the fix can be A/B'd live against the defect it fixes.
|
||||
("SoundOutputLimiter", "1"),
|
||||
("uiScale", "0.9"),
|
||||
("farclip", "777"),
|
||||
// The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own
|
||||
@@ -349,6 +354,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
"enableambience" => knobs.sound.ambience_enabled = v != 0.0,
|
||||
// The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`).
|
||||
"soundreverb" => knobs.sound.reverb = v != 0.0,
|
||||
"soundoutputlimiter" => knobs.sound.limiter = v != 0.0,
|
||||
"uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5),
|
||||
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
|
||||
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
|
||||
@@ -518,7 +524,7 @@ fn sync_cvars(
|
||||
);
|
||||
script.register_cvars(REGISTERED.iter().copied());
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 30] = [
|
||||
let session: [(&str, String); 31] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -527,6 +533,7 @@ fn sync_cvars(
|
||||
("EnableMusic", flag(sound.music_enabled)),
|
||||
("EnableAmbience", flag(sound.ambience_enabled)),
|
||||
("SoundReverb", flag(sound.reverb)),
|
||||
("SoundOutputLimiter", flag(sound.limiter)),
|
||||
("uiScale", scale.0.to_string()),
|
||||
("farclip", view.farclip.to_string()),
|
||||
("deselectOnClick", flag(click.deselect_on_click)),
|
||||
@@ -737,6 +744,8 @@ mod tests {
|
||||
assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled);
|
||||
// Welded like the rest — and deliberately NOT the binary's registrar "1" (1153).
|
||||
assert_eq!(d["SoundReverb"] != 0.0, sound.reverb);
|
||||
assert_eq!(d["SoundOutputLimiter"] != 0.0, sound.limiter);
|
||||
assert!(sound.limiter, "the output limiter ships on (decision 1551)");
|
||||
assert!(!sound.reverb, "zone reverb ships off (decision 1153)");
|
||||
assert_eq!(d["uiScale"], DEFAULT_UI_SCALE);
|
||||
// ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the
|
||||
|
||||
@@ -594,9 +594,14 @@ fn debug_panel_ui(
|
||||
egui::Slider::new(&mut sound_cfg.ambience, 0.0..=1.0)
|
||||
.text("ambience"),
|
||||
);
|
||||
ui.checkbox(&mut sound_cfg.limiter, "output limiter (1551)");
|
||||
ui.separator();
|
||||
ui.label("kit probe: a SoundEntries id or name");
|
||||
ui.text_edit_singleline(&mut s.kit_query);
|
||||
ui.add(
|
||||
egui::Slider::new(&mut s.play_copies, 1..=16)
|
||||
.text("copies, one frame"),
|
||||
);
|
||||
if ui.button("Play kit").clicked() {
|
||||
s.play_kit = true;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
//! [`super::ui`]'s `PlaySound` seam, but from the glue screens (which have no Lua VM).
|
||||
//! - **Music**: the glue title theme (`GlueParent.lua`'s `CurrentGlueMusic`,
|
||||
//! `Sound\Music\GlueScreenMusic\wow_main_theme.mp3`), streamed from the login screen on
|
||||
//! (decision 0539) and kept across the glue screens (the ref keeps it through select ⇄ create); entering the world
|
||||
//! fades it out on the backend (drop the handle — the ramp finishes on kira's thread, the
|
||||
//! [`super::zone`] music-stop shape). Re-entering the glue after a logout starts it again.
|
||||
//! (decision 0539) and kept across the glue screens (the ref keeps it through select ⇄ create).
|
||||
//! **The click into the world does not end it** (1550/1553, wow-re §5): it plays unbroken through
|
||||
//! the whole map load, and the stop is armed by the *load draining* — a 3.0 s fade, still behind
|
||||
//! the loading screen. Re-entering the glue after a logout starts it again.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -20,8 +21,20 @@ use super::{mixer, SoundConfig, SoundOutput};
|
||||
|
||||
/// The glue music file — a frozen fact of the 1.12 GlueXML (`GlueParent.lua`, `CurrentGlueMusic`).
|
||||
const GLUE_MUSIC: &str = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3";
|
||||
/// The out-fade on entering the world.
|
||||
const GLUE_MUSIC_FADE_OUT_MS: u64 = 2000;
|
||||
|
||||
/// The theme's own volume, under the Music slider: **0.8** — `0x45aeb0` starts the glue stream at
|
||||
/// `0x3f4ccccd` on both its arms, where the city-intro playlist and the in-world Lua `PlayMusic`
|
||||
/// use 1.0f (wow-re §5 `glue-music-world-entry.md` §5c). It is a plain scalar on the MusicVolume
|
||||
/// category (the stream's flag word is 2, bit `0x2` = `[0x87cef8]`), exactly like a zone track's
|
||||
/// kit volume, so it multiplies rather than replaces the slider.
|
||||
const GLUE_MUSIC_VOLUME: f32 = 0.8;
|
||||
|
||||
/// The stop-fade armed when the world's load drains: **3.0 s**, `[0x803248]` in `.rdata`
|
||||
/// (`0x40400000`) — the constant `0x45aeb0`'s NULL arm hands `0x45b050`, and the fade is **linear
|
||||
/// in amplitude**, not in dB (`0x7a5a50` ramps FMOD's 0–255 level; §5 §2). Not 1.0 s: that is
|
||||
/// `StopGlueMusic`'s literal, and `StopGlueMusic` is a `"movie"`-screen call the world entry never
|
||||
/// makes.
|
||||
const GLUE_MUSIC_FADE_OUT_MS: u64 = 3000;
|
||||
|
||||
/// A glue-screen `PlaySound` — the SoundEntries kit name the 1.12 GlueXML plays for this click.
|
||||
#[derive(Message)]
|
||||
@@ -29,11 +42,16 @@ pub(crate) struct GlueSound(pub(crate) &'static str);
|
||||
|
||||
/// The held glue-music stream (non-`Sync` handle — non-Send state, like [`super::zone`]'s), plus
|
||||
/// its starvation watch (decision 1109). The handle is held through the stop-fade, not dropped at
|
||||
/// it: the fade's 2 s ride through the world-entry load burst is exactly the crackle-prone window,
|
||||
/// and [`watch_glue_music`] can only see a stream it still holds.
|
||||
/// it: the fade rides out under the world-entry load burst, which is exactly the crackle-prone
|
||||
/// window, and [`watch_glue_music`] can only see a stream it still holds. Since 1550 the theme
|
||||
/// itself rides that whole burst too — all the more reason for the watch to be able to see it.
|
||||
struct GlueMusic {
|
||||
handle: Option<mixer::StreamingSoundHandle<kira::sound::FromFileError>>,
|
||||
watch: mixer::StreamWatch,
|
||||
/// The stop-fade is armed and running. The per-frame slider feed stands off while it is set,
|
||||
/// so a live `MusicVolume` change cannot stomp the ramp back up to full mid-fade — the hazard
|
||||
/// [`super::zone`]'s ambience crossfade documents from the other side.
|
||||
fading: bool,
|
||||
}
|
||||
|
||||
impl Default for GlueMusic {
|
||||
@@ -41,6 +59,7 @@ impl Default for GlueMusic {
|
||||
Self {
|
||||
handle: None,
|
||||
watch: mixer::StreamWatch::new("glue music"),
|
||||
fading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,9 +101,15 @@ fn start_glue_music(
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
config: Res<SoundConfig>,
|
||||
) {
|
||||
if music.handle.is_some() {
|
||||
if music.handle.is_some() && !music.fading {
|
||||
return; // still playing (select ⇄ create hops re-enter CharSelect)
|
||||
}
|
||||
// A theme caught mid-fade is NOT "still playing": returning to the glue inside the 3 s ramp
|
||||
// must bring it back at once. The reference gets there by bookkeeping — the enter-world stop
|
||||
// clears the cached track name (`0x45afec`), so `SetGlueScreen`'s next `PlayGlueMusic` misses
|
||||
// the same-name early-out, stops the outgoing stream and opens a fresh one. Same outcome here:
|
||||
// the old handle is dropped below with its ramp already armed, so it finishes fading on the
|
||||
// backend while the new stream starts at full.
|
||||
let (Some(assets), Some(mixer_ref)) = (assets, out.mixer.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
@@ -99,38 +124,85 @@ fn start_glue_music(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let amp = config.category_amp(SoundCategory::Music);
|
||||
let amp = config.category_amp(SoundCategory::Music) * GLUE_MUSIC_VOLUME;
|
||||
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
|
||||
Ok(h) => {
|
||||
info!("glue music: {GLUE_MUSIC}");
|
||||
music.handle = Some(h);
|
||||
music.fading = false;
|
||||
music.watch.reset();
|
||||
}
|
||||
Err(e) => debug!("glue music: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entering the world: fade the theme out on the backend. The handle is deliberately kept — the
|
||||
/// [`GlueMusic`] docs say why — and reaped by [`watch_glue_music`] once the fade lands on
|
||||
/// `Stopped`.
|
||||
fn stop_glue_music(mut music: NonSendMut<GlueMusic>) {
|
||||
/// **The world's load drained — arm the theme's fade** (decision 1553, correcting 1550's trigger).
|
||||
///
|
||||
/// This is the reference's own trigger, and it is not a music event at all: `CGlueMgr::Update`
|
||||
/// state 8 runs a *second* pass every frame after the entry (`[0xb41d94] == 1`), spinning on the
|
||||
/// AsyncFileLoader (`0x443e20`) while any read is outstanding; the frame the queue drains it calls
|
||||
/// `0x45aeb0(NULL)` → `0x45b050(3.0f)` and then sends `CMSG_PLAYER_LOGIN`. So the theme sounds
|
||||
/// through the click and the whole map load, and dies on a 3.0 s fade armed *behind* the loading
|
||||
/// screen — never on the click (1550's own `stop_glue_music`), and never on the first world track
|
||||
/// (1550's replacement, which was the right *feel* found from a misread of the branch).
|
||||
///
|
||||
/// benilla's analogue of "the async queue drained" is the cover's own clear condition — every
|
||||
/// wanted tile spawned, placements up, colliders quiet ([`crate::loading_screen`]) — so the fade is
|
||||
/// armed on `world_hold`'s **falling edge**. The edge is tracked every frame but only acted on
|
||||
/// **in the world**, which is what excludes the logout blackout: that cover drops on the frame the
|
||||
/// state leaves `InWorld` (0738), and firing there would fade the theme the glue had just restarted.
|
||||
///
|
||||
/// The handle is deliberately kept through the fade — the [`GlueMusic`] docs say why — and reaped
|
||||
/// by [`watch_glue_music`] once the fade lands on `Stopped`.
|
||||
fn hand_off_glue_music(
|
||||
config: Res<SoundConfig>,
|
||||
state: Res<State<ClientState>>,
|
||||
mut music: NonSendMut<GlueMusic>,
|
||||
mut was_covered: Local<bool>,
|
||||
) {
|
||||
let covered = config.world_hold;
|
||||
let fell = std::mem::replace(&mut *was_covered, covered) && !covered;
|
||||
if !fell || *state.get() != ClientState::InWorld {
|
||||
return;
|
||||
}
|
||||
if let Some(h) = music.handle.as_mut() {
|
||||
info!("glue music: the world's load drained — {GLUE_MUSIC_FADE_OUT_MS} ms fade");
|
||||
h.stop(mixer::fade(GLUE_MUSIC_FADE_OUT_MS));
|
||||
music.fading = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame stream health + handle reaping: the starvation watch (decision 1109) over the held
|
||||
/// theme, and the drop once it reaches `Stopped` — after the entry fade lands, or at the theme's
|
||||
/// theme, and the drop once it reaches `Stopped` — after the handoff fade lands, or at the theme's
|
||||
/// natural end. On the glue screens that end re-arms [`start_glue_music`], which brings the theme
|
||||
/// back from the top — the login screen no longer falls silent for good after one play-through
|
||||
/// (the reference's theme keeps going; it rides the same music machinery as zone tracks,
|
||||
/// `PlayGlueMusic` → `0x45aeb0`, which re-selects when a track ends).
|
||||
fn watch_glue_music(mut music: NonSendMut<GlueMusic>, time: Res<Time>) {
|
||||
/// back from the top — the login screen no longer falls silent for good after one play-through.
|
||||
/// **In the world it does not**, and that asymmetry is the point of [`start_glue_music`]'s run
|
||||
/// condition: a theme that outlives a music-less zone must run out, not loop under it (1550).
|
||||
fn watch_glue_music(mut music: NonSendMut<GlueMusic>, time: Res<Time>, config: Res<SoundConfig>) {
|
||||
let music = &mut *music;
|
||||
// The Music slider is live on this stream, as it is on the world's (wow-re §5 §5c: the
|
||||
// `MusicVolume` handler's re-apply walker `0x7a6660(ecx=2)` matches the glue wrapper, so moving
|
||||
// the slider rescales the playing theme in place — mid-loading-screen included). Stand off
|
||||
// while the stop-fade runs; see [`GlueMusic::fading`].
|
||||
if !music.fading {
|
||||
if let Some(h) = music.handle.as_mut() {
|
||||
h.set_volume(
|
||||
mixer::amp_to_db(config.category_amp(SoundCategory::Music) * GLUE_MUSIC_VOLUME),
|
||||
mixer::glide(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(h) = music.handle.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if h.state() == kira::sound::PlaybackState::Stopped {
|
||||
// The theme's end, said out loud — the counterpart to the `glue music: <path>` start line.
|
||||
// With the theme now outliving world entry (1550), "did it actually stop, or is it playing
|
||||
// under the world?" is a real question about a stream nothing else reports on, and it must
|
||||
// be answerable from an ordinary log rather than from a mix capture.
|
||||
info!("glue music: stream ended");
|
||||
music.handle = None;
|
||||
music.fading = false;
|
||||
music.watch.reset();
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +212,6 @@ fn watch_glue_music(mut music: NonSendMut<GlueMusic>, time: Res<Time>) {
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_message::<GlueSound>()
|
||||
.insert_non_send_resource(GlueMusic::default())
|
||||
.add_systems(OnEnter(ClientState::InWorld), stop_glue_music)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -149,9 +220,13 @@ pub(super) fn plugin(app: &mut App) {
|
||||
// same `wow_main_theme` — decision 0539) and keeps across the glue screens.
|
||||
start_glue_music
|
||||
.run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect))),
|
||||
// No run condition: the stop-fade rides into `InWorld`, and that leg is exactly
|
||||
// the window the watch exists for (1109).
|
||||
watch_glue_music.after(start_glue_music),
|
||||
// No run condition on either: the theme rides `InWorld` now (1550), so its handoff
|
||||
// and the watch both have to keep running there — and the entry load burst is
|
||||
// exactly the window the watch exists for (1109).
|
||||
hand_off_glue_music,
|
||||
watch_glue_music
|
||||
.after(start_glue_music)
|
||||
.after(hand_off_glue_music),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -704,19 +704,30 @@ pub(super) fn apply_kit_debug(
|
||||
Err(_) => KitRef::Name(&query),
|
||||
};
|
||||
let listener = listener.pos;
|
||||
match play_kit(
|
||||
&mut kits,
|
||||
&assets,
|
||||
&mut out,
|
||||
&config,
|
||||
listener,
|
||||
kit_ref,
|
||||
None,
|
||||
SoundCategory::Sfx,
|
||||
) {
|
||||
Ok(()) => info!("sound debug: kit \"{query}\" played"),
|
||||
Err(e) => warn!("sound debug: kit \"{query}\" — {e:#}"),
|
||||
// `copies` fires the kit N times in ONE frame — the overlap probe (decision 1551). Five copies
|
||||
// of kit 3116 is mass Fortitude on a full party: same kit, same instant, sample-aligned. The
|
||||
// per-kit gates still apply, so a kit carrying the 0x20 no-duplicate bit collapses to one
|
||||
// however many copies are asked for — which is the honest answer for that kit.
|
||||
let copies = s.play_copies.max(1);
|
||||
for _ in 0..copies {
|
||||
if let Err(e) = play_kit(
|
||||
&mut kits,
|
||||
&assets,
|
||||
&mut out,
|
||||
&config,
|
||||
listener,
|
||||
match kit_ref {
|
||||
KitRef::Id(id) => KitRef::Id(id),
|
||||
KitRef::Name(n) => KitRef::Name(n),
|
||||
},
|
||||
None,
|
||||
SoundCategory::Sfx,
|
||||
) {
|
||||
warn!("sound debug: kit \"{query}\" — {e:#}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
info!("sound debug: kit \"{query}\" played x{copies}");
|
||||
}
|
||||
|
||||
/// `OnExit(InWorld)`: every live kit channel dies with the world — one-shots mid-flight and
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
//! The **output limiter** — the mix's last stage, and the one that stops a busy soundscape from
|
||||
//! tearing (decision 1551).
|
||||
//!
|
||||
//! ## What it replaces
|
||||
//!
|
||||
//! Every WoW SFX ships mastered to full scale: `HolyProtection.wav` (the Fortitude buff, the
|
||||
//! director's own example) peaks at **1.000**, `MetalShieldBlock1.wav` at 1.000,
|
||||
//! `LightningBoltImpact.wav` at 0.992 — measured off the shipped `sound.MPQ`. benilla's owned
|
||||
//! gain chain is `category · v · rolloff · near_field`, every factor `<= 1.0`, so a single close
|
||||
//! kit at `SoundEntries.Volume 1.0` (2 175 of the 4 623 rows) *already sits on 0 dBFS*. There is
|
||||
//! no headroom anywhere in the chain, and the mix is a plain sum.
|
||||
//!
|
||||
//! So two of them is +6 dB past full scale and five is +14, and kira answers that with
|
||||
//! `frame.clamp(-1.0, 1.0)` (`backend/renderer.rs`) — a squared-off waveform, which is broadband
|
||||
//! distortion, which is what a speaker breaking sounds like. A priest's Prayer of Fortitude
|
||||
//! lands `HolyProtection` on five party members inside one frame: five sample-aligned copies of
|
||||
//! one 0 dBFS file, summing to exactly 5.0. Nothing upstream prevents it — `SoundEntries` id 3116
|
||||
//! carries `Flags 0x0000`, so the no-duplicate bit (0x20, which 2 776 of the 4 623 rows *do*
|
||||
//! carry) does not apply to it.
|
||||
//!
|
||||
//! ## What it does
|
||||
//!
|
||||
//! A **look-ahead brickwall limiter**, stereo-linked, with a mathematically exact guarantee of no
|
||||
//! overshoot — so nothing ever reaches the renderer's clamp and the clamp stops being audible.
|
||||
//! Three stages, all fixed-size and allocation-free on the render path:
|
||||
//!
|
||||
//! 1. **Required gain.** Per frame, `required = min(1, CEILING / max(|L|, |R|))` — stereo-linked so
|
||||
//! a limited transient never shifts the stereo image.
|
||||
//! 2. **Anticipation.** A sliding *minimum* of `required` over the next [`LOOKAHEAD_MS`], then a
|
||||
//! moving *average* of that minimum over the same span. The audio is delayed by one look-ahead
|
||||
//! so the gain is already down when the transient arrives. The two windows are what make the
|
||||
//! bound exact: every term of the average is a minimum over a window that still contains the
|
||||
//! sample being scaled, so the average is `<= required` at that sample — a smooth gain curve
|
||||
//! that provably never overshoots. (The naive one-pole attack does overshoot: it approaches
|
||||
//! its target asymptotically and lets the first millisecond of a +14 dB transient straight
|
||||
//! through, which is the part you actually hear.)
|
||||
//! 3. **Release.** The gain returns to unity exponentially over [`RELEASE_MS`] instead of snapping
|
||||
//! back the instant the peak passes. A limiter whose gain recovers in two milliseconds is
|
||||
//! modulating the signal at audio rate, which is its own distortion — worse on bass than the
|
||||
//! clipping it replaces.
|
||||
//!
|
||||
//! ## Why this and not fidelity
|
||||
//!
|
||||
//! This is **below** the decision-0070 seam, not across it: WoW's owned parameter math upstream is
|
||||
//! untouched, and the thing being replaced is not a reference behaviour but *kira's* hard clamp.
|
||||
//! The reference delegates its whole audible mix to FMOD 3 and its own headroom mechanism is a
|
||||
//! different one — the SFX-bus auto-duck (`0x457960`, wow-re `benilla-pins.md` B15: the
|
||||
//! SoundVolume category dips to 50 % while one-shots ring, restoring over 3 s), which benilla owes
|
||||
//! separately and which is a 6 dB constant, not a bound. Modern engines put a limiter here for
|
||||
//! exactly this reason — FMOD Studio ships one on the master bus by default, and Wwise's own
|
||||
//! guidance is a peak limiter on the master output. `SoundOutputLimiter 0` turns it off to A/B
|
||||
//! against what the director reported.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use kira::effect::{Effect, EffectBuilder};
|
||||
use kira::info::Info;
|
||||
use kira::Frame;
|
||||
|
||||
use super::meter::MixLevel;
|
||||
|
||||
/// The output ceiling. A hair under full scale so the renderer's own `clamp` is never the thing
|
||||
/// shaping the waveform — if this ever fires, something upstream of it is broken.
|
||||
const CEILING: f32 = 0.99;
|
||||
|
||||
/// The look-ahead span: both the anticipation window and the added output latency.
|
||||
///
|
||||
/// 2 ms is long enough to swallow the attack of a percussive impact (the shortest thing in this
|
||||
/// game's SFX is a shield clang, whose rise is ~1 ms) and short enough to be nothing at all as
|
||||
/// latency — nothing in WoW is rhythm-critical, and the device buffer above it is already 43 ms
|
||||
/// (decision 1026's `TARGET_BUFFER_FRAMES`).
|
||||
const LOOKAHEAD_MS: f32 = 2.0;
|
||||
|
||||
/// How long the gain takes to return to unity once the peak has passed. Long enough that the
|
||||
/// gain is not modulating at audio rate (which would intermodulate the bass), short enough that
|
||||
/// a burst of impacts does not leave the world quiet behind it.
|
||||
const RELEASE_MS: f32 = 120.0;
|
||||
|
||||
/// The ceiling, for the seam's offline harness to assert against.
|
||||
#[cfg(test)]
|
||||
pub(super) fn ceiling() -> f32 {
|
||||
CEILING
|
||||
}
|
||||
|
||||
/// Install the limiter as the main track's last stage. `enabled` is the live `SoundOutputLimiter`
|
||||
/// CVar cell — read once per block, so flipping it applies without a restart; the delay line runs
|
||||
/// either way, so the toggle never steps the output.
|
||||
pub(super) fn install(
|
||||
builder: &mut kira::track::MainTrackBuilder,
|
||||
level: &Arc<MixLevel>,
|
||||
enabled: &Arc<AtomicBool>,
|
||||
) {
|
||||
builder.add_effect(LimiterBuilder {
|
||||
level: Arc::clone(level),
|
||||
enabled: Arc::clone(enabled),
|
||||
});
|
||||
}
|
||||
|
||||
struct LimiterBuilder {
|
||||
level: Arc<MixLevel>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl EffectBuilder for LimiterBuilder {
|
||||
type Handle = ();
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
|
||||
(
|
||||
Box::new(Limiter {
|
||||
level: self.level,
|
||||
enabled: self.enabled,
|
||||
core: LimiterCore::new(),
|
||||
}),
|
||||
(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct Limiter {
|
||||
level: Arc<MixLevel>,
|
||||
enabled: Arc<AtomicBool>,
|
||||
core: LimiterCore,
|
||||
}
|
||||
|
||||
impl Effect for Limiter {
|
||||
fn init(&mut self, sample_rate: u32, _internal_buffer_size: usize) {
|
||||
self.core.resize(sample_rate);
|
||||
}
|
||||
|
||||
fn on_change_sample_rate(&mut self, sample_rate: u32) {
|
||||
self.core.resize(sample_rate);
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], _dt: f64, _info: &Info) {
|
||||
let bypass = !self.enabled.load(Ordering::Relaxed);
|
||||
let mut deepest = 1.0f32;
|
||||
for f in input.iter_mut() {
|
||||
let (out, gain) = self.core.step(*f, bypass);
|
||||
*f = out;
|
||||
deepest = deepest.min(gain);
|
||||
}
|
||||
self.level.gain(deepest);
|
||||
}
|
||||
}
|
||||
|
||||
/// The limiter's DSP, free of kira and of the atomics — so the whole guarantee is testable
|
||||
/// offline (see the tests below, which are the proof that "never overshoots" is a fact and not a
|
||||
/// hope).
|
||||
pub(super) struct LimiterCore {
|
||||
/// The look-ahead audio delay, `lookahead` frames deep.
|
||||
delay: Vec<Frame>,
|
||||
delay_w: usize,
|
||||
/// Monotonically-increasing `(sample index, required gain)` — the sliding-minimum window.
|
||||
/// Never holds more than `lookahead + 1` entries, and its capacity is reserved up front, so
|
||||
/// the pushes cannot allocate.
|
||||
win: VecDeque<(u64, f32)>,
|
||||
/// Index of the next input sample.
|
||||
n: u64,
|
||||
/// The last `lookahead` sliding-minimum outputs and their running sum — the smoothing average.
|
||||
hist: Vec<f32>,
|
||||
hist_w: usize,
|
||||
hist_sum: f64,
|
||||
/// The gain actually applied to the sample leaving the delay line.
|
||||
gain: f32,
|
||||
/// Per-sample exponential approach to unity.
|
||||
release: f32,
|
||||
lookahead: usize,
|
||||
}
|
||||
|
||||
impl LimiterCore {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
delay: Vec::new(),
|
||||
delay_w: 0,
|
||||
win: VecDeque::new(),
|
||||
n: 0,
|
||||
hist: Vec::new(),
|
||||
hist_w: 0,
|
||||
hist_sum: 0.0,
|
||||
gain: 1.0,
|
||||
release: 0.0,
|
||||
lookahead: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// (Re)size every buffer for `sample_rate`. The only allocating call — kira guarantees it runs
|
||||
/// off the render path (`Effect::init` / `on_change_sample_rate`).
|
||||
pub(super) fn resize(&mut self, sample_rate: u32) {
|
||||
let rate = sample_rate.max(1) as f32;
|
||||
self.lookahead = ((LOOKAHEAD_MS / 1000.0 * rate).round() as usize).max(1);
|
||||
self.delay.clear();
|
||||
self.delay.resize(self.lookahead, Frame::ZERO);
|
||||
self.delay_w = 0;
|
||||
self.win.clear();
|
||||
self.win.reserve(self.lookahead + 2);
|
||||
self.n = 0;
|
||||
self.hist.clear();
|
||||
self.hist.resize(self.lookahead, 1.0);
|
||||
self.hist_w = 0;
|
||||
self.hist_sum = self.lookahead as f64;
|
||||
self.gain = 1.0;
|
||||
// Exponential approach to unity with a time constant of RELEASE_MS.
|
||||
self.release = (-1000.0 / (RELEASE_MS * rate)).exp();
|
||||
}
|
||||
|
||||
/// Consume one input frame, emit one output frame plus the gain applied to it.
|
||||
///
|
||||
/// `bypass` steers the *target* to unity rather than short-circuiting the delay: the release
|
||||
/// envelope then carries the gain back smoothly, so toggling the CVar mid-sound is a fade and
|
||||
/// never a step.
|
||||
#[inline]
|
||||
pub(super) fn step(&mut self, input: Frame, bypass: bool) -> (Frame, f32) {
|
||||
// Stage 1 — this sample's required gain, stereo-linked.
|
||||
let peak = input.left.abs().max(input.right.abs());
|
||||
let required = if peak > CEILING { CEILING / peak } else { 1.0 };
|
||||
|
||||
// Stage 2a — sliding minimum of `required` over the look-ahead window ending here.
|
||||
while self.win.back().is_some_and(|&(_, g)| g >= required) {
|
||||
self.win.pop_back();
|
||||
}
|
||||
self.win.push_back((self.n, required));
|
||||
let oldest = self.n.saturating_sub(self.lookahead as u64);
|
||||
while self.win.front().is_some_and(|&(i, _)| i < oldest) {
|
||||
self.win.pop_front();
|
||||
}
|
||||
let window_min = self.win.front().map_or(1.0, |&(_, g)| g);
|
||||
|
||||
// Stage 2b — moving average of that minimum over the same span. Every term is a minimum
|
||||
// over a window that still contains the sample leaving the delay line, so the average
|
||||
// cannot exceed that sample's own required gain. That is the no-overshoot proof.
|
||||
self.hist_sum += f64::from(window_min) - f64::from(self.hist[self.hist_w]);
|
||||
self.hist[self.hist_w] = window_min;
|
||||
self.hist_w = (self.hist_w + 1) % self.lookahead;
|
||||
let smoothed = if bypass {
|
||||
1.0
|
||||
} else {
|
||||
(self.hist_sum / self.lookahead as f64) as f32
|
||||
};
|
||||
|
||||
// Stage 3 — instant attack (the anticipation already made it gradual), slow release.
|
||||
let recovered = 1.0 - (1.0 - self.gain) * self.release;
|
||||
self.gain = smoothed.min(recovered);
|
||||
|
||||
// The delay line: emit the frame from one look-ahead ago, scaled by this gain.
|
||||
let out = self.delay[self.delay_w] * self.gain;
|
||||
self.delay[self.delay_w] = input;
|
||||
self.delay_w = (self.delay_w + 1) % self.lookahead;
|
||||
self.n += 1;
|
||||
(out, self.gain)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RATE: u32 = 48_000;
|
||||
|
||||
fn core() -> LimiterCore {
|
||||
let mut c = LimiterCore::new();
|
||||
c.resize(RATE);
|
||||
c
|
||||
}
|
||||
|
||||
/// Run `frames` through and return the output, dropping the priming look-ahead.
|
||||
fn run(c: &mut LimiterCore, frames: &[Frame]) -> Vec<Frame> {
|
||||
frames.iter().map(|f| c.step(*f, false).0).collect()
|
||||
}
|
||||
|
||||
/// A mono impulse train at N× full scale — the mass-buff case, five sample-aligned copies of
|
||||
/// one 0 dBFS file. **Nothing leaves the limiter past the ceiling**, at any overload.
|
||||
#[test]
|
||||
fn never_exceeds_the_ceiling_at_any_overload() {
|
||||
for n in [2.0f32, 5.0, 12.0, 40.0] {
|
||||
let mut c = core();
|
||||
// 200 ms of a loud 440 Hz tone: continuous, so the release never gets to recover and
|
||||
// the steady-state bound is exercised, not just the transient.
|
||||
let frames: Vec<Frame> = (0..RATE / 5)
|
||||
.map(|i| {
|
||||
let s = n * (i as f32 * 440.0 * std::f32::consts::TAU / RATE as f32).sin();
|
||||
Frame::from_mono(s)
|
||||
})
|
||||
.collect();
|
||||
let out = run(&mut c, &frames);
|
||||
let peak = out
|
||||
.iter()
|
||||
.map(|f| f.left.abs().max(f.right.abs()))
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(
|
||||
peak <= CEILING + 1e-5,
|
||||
"{n}x overload leaked {peak} past the {CEILING} ceiling"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hard case a one-pole attack fails: silence, then a single full-amplitude sample at
|
||||
/// 5× scale with no warning. The look-ahead must already have the gain down.
|
||||
#[test]
|
||||
fn a_bare_transient_out_of_silence_does_not_leak() {
|
||||
let mut c = core();
|
||||
let mut frames = vec![Frame::ZERO; 500];
|
||||
frames[300] = Frame::from_mono(5.0);
|
||||
frames[301] = Frame::from_mono(-5.0);
|
||||
let out = run(&mut c, &frames);
|
||||
let peak = out
|
||||
.iter()
|
||||
.map(|f| f.left.abs().max(f.right.abs()))
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(peak <= CEILING + 1e-5, "transient leaked at {peak}");
|
||||
}
|
||||
|
||||
/// Below the ceiling the limiter is a pure delay: unity gain, sample-for-sample, no colour.
|
||||
/// A limiter that touches a quiet mix is a bug, not a safety net.
|
||||
#[test]
|
||||
fn quiet_material_passes_through_untouched() {
|
||||
let mut c = core();
|
||||
let frames: Vec<Frame> = (0..2000)
|
||||
.map(|i| {
|
||||
Frame::from_mono(
|
||||
0.5 * (i as f32 * 220.0 * std::f32::consts::TAU / RATE as f32).sin(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let out = run(&mut c, &frames);
|
||||
let lookahead = c.lookahead;
|
||||
for (i, f) in out.iter().enumerate().skip(lookahead) {
|
||||
let want = frames[i - lookahead].left;
|
||||
assert!(
|
||||
(f.left - want).abs() < 1e-6,
|
||||
"sample {i}: {} != {want}",
|
||||
f.left
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain returns to unity after the loud part ends — a limiter that stays down has eaten
|
||||
/// the world's volume. [`RELEASE_MS`] is an exponential *time constant*, so the check is
|
||||
/// against its arithmetic: 250 ms is ~2.1 τ (the gap should be ~12 % of its start), 1 s is
|
||||
/// ~8.3 τ (essentially closed).
|
||||
#[test]
|
||||
fn the_gain_recovers_after_the_burst() {
|
||||
let mut c = core();
|
||||
let loud: Vec<Frame> = (0..RATE / 20).map(|_| Frame::from_mono(5.0)).collect();
|
||||
run(&mut c, &loud);
|
||||
assert!(c.gain < 0.3, "did not engage: {}", c.gain);
|
||||
let engaged = c.gain;
|
||||
run(&mut c, &vec![Frame::ZERO; (RATE / 4) as usize]);
|
||||
let after_250ms = c.gain;
|
||||
assert!(
|
||||
after_250ms > 1.0 - (1.0 - engaged) * 0.2,
|
||||
"recovery is slower than its own time constant: {after_250ms}"
|
||||
);
|
||||
run(&mut c, &vec![Frame::ZERO; (RATE * 3 / 4) as usize]);
|
||||
assert!(c.gain > 0.999, "did not recover: {}", c.gain);
|
||||
}
|
||||
|
||||
/// Stereo linking: a peak on one channel scales both by the same gain, so a limited transient
|
||||
/// cannot swing the stereo image.
|
||||
#[test]
|
||||
fn limiting_is_stereo_linked() {
|
||||
let mut c = core();
|
||||
let frames: Vec<Frame> = (0..2000)
|
||||
.map(|_| Frame {
|
||||
left: 4.0,
|
||||
right: 1.0,
|
||||
})
|
||||
.collect();
|
||||
let out = run(&mut c, &frames);
|
||||
for f in out.iter().skip(c.lookahead + 200) {
|
||||
assert!(
|
||||
(f.left / f.right - 4.0).abs() < 1e-3,
|
||||
"image moved: {} / {}",
|
||||
f.left,
|
||||
f.right
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bypass is a *target* change, not a short circuit: the delay keeps running, so the output
|
||||
/// stays sample-aligned and the gain walks back to unity instead of stepping.
|
||||
#[test]
|
||||
fn bypass_keeps_the_delay_and_fades_rather_than_steps() {
|
||||
let mut c = core();
|
||||
for _ in 0..RATE / 20 {
|
||||
c.step(Frame::from_mono(5.0), false);
|
||||
}
|
||||
let engaged = c.gain;
|
||||
assert!(engaged < 0.3);
|
||||
let mut last = engaged;
|
||||
// Half a second is ~4.2 release time constants: no single-sample step anywhere along the
|
||||
// way, and unity by the end.
|
||||
for _ in 0..RATE / 2 {
|
||||
let (_, g) = c.step(Frame::from_mono(5.0), true);
|
||||
assert!(g - last < 0.01, "stepped from {last} to {g}");
|
||||
last = g;
|
||||
}
|
||||
assert!(last > 0.98, "bypass did not reach unity: {last}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The **output meter** — what the mix's level actually *is*, in numbers (decision 1551).
|
||||
//!
|
||||
//! The three crackle hunts before this one (1026, 1109, 1112/1114) each ended by adding the meter
|
||||
//! that would have named the mechanism: the callback-deadline load, the stream-decoder liveness,
|
||||
//! the HAL's own IO cycle, the recorded waveform. Every one of them watches *timing*. None of
|
||||
//! them watches **amplitude** — and amplitude is the one thing that goes wrong when a lot of
|
||||
//! sounds happen at once, which is exactly the condition the director reports ("a lot of mobs
|
||||
//! attacking same time... it gets really dirty, like a speaker breaking").
|
||||
//!
|
||||
//! It cannot be read off any existing counter, because a clipped mix is *healthy* by all of them:
|
||||
//! the callback met its deadline, no decoder starved, the OS delivered every cycle on time. The
|
||||
//! mix was computed perfectly — it was just louder than full scale, and kira's renderer answers
|
||||
//! that with a hard `clamp(-1.0, 1.0)` (`backend/renderer.rs`), which is a squared-off waveform,
|
||||
//! i.e. broadband distortion. So this meter sits on the main track **ahead of the limiter** and
|
||||
//! records what the game *asked* for:
|
||||
//!
|
||||
//! - **`peak`** — the largest `|sample|` the summed mix reached. `> 1.0` means the request did not
|
||||
//! fit; `4.7` means the game asked for 4.7× full scale and (before 1551) got a clipped 1.0.
|
||||
//! - **`over`** — how many samples were past full scale. One is a tick; a hundred thousand is the
|
||||
//! report the director filed.
|
||||
//! - **`reduction`** — the deepest gain the limiter had to pull to make it fit ([`super::limiter`]
|
||||
//! writes it here), so the log line reads as one story: asked for this, allowed that.
|
||||
//!
|
||||
//! The audio-thread half is three atomics and a compare per sample. The main-thread half drains
|
||||
//! them once per report window in `sound::poll_mix_health`.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use kira::effect::{Effect, EffectBuilder};
|
||||
use kira::info::Info;
|
||||
use kira::Frame;
|
||||
|
||||
/// The mix's level, shared audio-thread → main-thread.
|
||||
///
|
||||
/// Peaks are stored as the **bit pattern** of a non-negative `f32`, which orders identically to
|
||||
/// the float itself — so `fetch_max`/`fetch_min` on the bits are exactly max/min on the values,
|
||||
/// with no lock and no CAS loop on the render path.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct MixLevel {
|
||||
/// Peak `|sample|` of the summed mix since the last [`Self::take`].
|
||||
peak_bits: AtomicU32,
|
||||
/// Samples past full scale since the last [`Self::take`].
|
||||
over: AtomicU64,
|
||||
/// The limiter's deepest gain since the last [`Self::take`] (1.0 = it never engaged).
|
||||
reduction_bits: AtomicU32,
|
||||
}
|
||||
|
||||
impl Default for MixLevel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
peak_bits: AtomicU32::new(0),
|
||||
over: AtomicU64::new(0),
|
||||
reduction_bits: AtomicU32::new(1.0f32.to_bits()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One window's reading, as [`MixLevel::take`] hands it to the reporter.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct LevelReading {
|
||||
/// Peak `|sample|` the summed mix reached. `> 1.0` = the mix did not fit in full scale.
|
||||
pub(super) peak: f32,
|
||||
/// Samples past full scale in the window.
|
||||
pub(super) over: u64,
|
||||
/// The limiter's deepest gain in the window (1.0 = never engaged).
|
||||
pub(super) reduction: f32,
|
||||
}
|
||||
|
||||
impl MixLevel {
|
||||
/// Fold one processed block's tally in (audio thread) — accumulated in locals first, so the
|
||||
/// render path pays atomics per *block*, not per sample.
|
||||
#[inline]
|
||||
fn block(&self, peak: f32, over: u64) {
|
||||
self.peak_bits.fetch_max(peak.to_bits(), Ordering::Relaxed);
|
||||
if over > 0 {
|
||||
self.over.fetch_add(over, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the limiter's applied gain in (audio thread — [`super::limiter`]).
|
||||
#[inline]
|
||||
pub(super) fn gain(&self, gain: f32) {
|
||||
self.reduction_bits
|
||||
.fetch_min(gain.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read and reset — one window's story (main thread).
|
||||
pub(super) fn take(&self) -> LevelReading {
|
||||
LevelReading {
|
||||
peak: f32::from_bits(self.peak_bits.swap(0, Ordering::Relaxed)),
|
||||
over: self.over.swap(0, Ordering::Relaxed),
|
||||
reduction: f32::from_bits(
|
||||
self.reduction_bits
|
||||
.swap(1.0f32.to_bits(), Ordering::Relaxed),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the meter on `builder` and return the cell it feeds. Always on: it is three atomics on
|
||||
/// the render path, and the alternative is what the last four crackle hunts each had to start
|
||||
/// from — the director's ear and no number.
|
||||
pub(super) fn install(builder: &mut kira::track::MainTrackBuilder, level: &Arc<MixLevel>) {
|
||||
builder.add_effect(MeterBuilder {
|
||||
level: Arc::clone(level),
|
||||
});
|
||||
}
|
||||
|
||||
struct MeterBuilder {
|
||||
level: Arc<MixLevel>,
|
||||
}
|
||||
|
||||
impl EffectBuilder for MeterBuilder {
|
||||
type Handle = ();
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
|
||||
(Box::new(Meter { level: self.level }), ())
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio-thread half: measure, never modify.
|
||||
struct Meter {
|
||||
level: Arc<MixLevel>,
|
||||
}
|
||||
|
||||
impl Effect for Meter {
|
||||
fn process(&mut self, input: &mut [Frame], _dt: f64, _info: &Info) {
|
||||
let mut peak = 0.0f32;
|
||||
let mut over = 0u64;
|
||||
for f in input.iter() {
|
||||
for mag in [f.left.abs(), f.right.abs()] {
|
||||
peak = peak.max(mag);
|
||||
over += u64::from(mag > 1.0);
|
||||
}
|
||||
}
|
||||
self.level.block(peak, over);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The float-bits ordering trick the lock-free peak/min rests on: for non-negative floats,
|
||||
/// `to_bits` is monotonic, so atomic max/min on the bits are max/min on the values.
|
||||
#[test]
|
||||
fn float_bits_order_like_the_floats_for_non_negatives() {
|
||||
let mut vals = [0.0f32, 1e-30, 0.5, 0.999, 1.0, 1.0001, 4.7, 1e30];
|
||||
for w in vals.windows(2) {
|
||||
assert!(w[0].to_bits() < w[1].to_bits(), "{} < {}", w[0], w[1]);
|
||||
}
|
||||
vals.reverse();
|
||||
let level = MixLevel::default();
|
||||
for v in vals {
|
||||
level.block(v, 0);
|
||||
}
|
||||
assert_eq!(level.take().peak, 1e30);
|
||||
}
|
||||
|
||||
/// A window's reading is the window's, not all time: `take` resets peak and count, and
|
||||
/// restores the "limiter never engaged" identity for the reduction.
|
||||
#[test]
|
||||
fn take_resets_the_window() {
|
||||
let level = MixLevel::default();
|
||||
level.block(0.5, 0);
|
||||
level.block(2.0, 2);
|
||||
level.block(1.5, 0);
|
||||
level.gain(0.25);
|
||||
let r = level.take();
|
||||
assert_eq!(r.peak, 2.0);
|
||||
assert_eq!(r.over, 2); // the one block that carried over-scale samples
|
||||
assert_eq!(r.reduction, 0.25);
|
||||
let r = level.take();
|
||||
assert_eq!(r.peak, 0.0);
|
||||
assert_eq!(r.over, 0);
|
||||
assert_eq!(r.reduction, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@
|
||||
//! of a sound to argue about.
|
||||
//!
|
||||
//! Two properties matter:
|
||||
//! - **Pre-clamp.** Main-track effects run before the renderer's hard clamp to ±1.0
|
||||
//! (`backend/renderer.rs`), so output clipping shows up as samples beyond full scale in the
|
||||
//! capture — measurable, not just audible.
|
||||
//! - **What is actually heard.** The tap is the main track's *last* effect, downstream of the
|
||||
//! meter and the limiter (decision 1551), so the capture is the audible result — the waveform
|
||||
//! to scan when a report survives everything upstream. The mix's raw, pre-limiter level is the
|
||||
//! [`super::meter`]'s job and lands in the health report as a number; the tap no longer has to
|
||||
//! double as the clipping detector it once was.
|
||||
//! - **Crash-safe file.** The writer patches the RIFF/data sizes on every flush, so the WAV is
|
||||
//! valid up to the last second even if the app exits hard; there is no finalize step to miss.
|
||||
//!
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
//! is the WoW↔FMOD convention pair; ours is Bevy↔kira, already unified by the decision-0002
|
||||
//! world transform upstream.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bevy::prelude::*;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
@@ -26,6 +29,9 @@ use kira::sound::FromFileError;
|
||||
use kira::track::{SendTrackBuilder, SendTrackHandle, SpatialTrackBuilder};
|
||||
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Mix, Tween};
|
||||
|
||||
use super::limiter;
|
||||
use super::meter::{self, LevelReading, MixLevel};
|
||||
|
||||
pub(crate) use benilla_formats::SoundProvider;
|
||||
|
||||
// Types crossing the seam (consumers hold handles to stop/steer a playing channel; dropping a
|
||||
@@ -126,6 +132,14 @@ pub(crate) struct Mixer {
|
||||
reverb_send: SendTrackHandle,
|
||||
/// The Freeverb parameters on the send — retuned per zone preset.
|
||||
reverb: ReverbHandle,
|
||||
/// The main track's level story — what the mix asked for, and what the limiter allowed
|
||||
/// (decision 1551). Written on the audio thread, drained by [`Mixer::take_level`].
|
||||
level: Arc<MixLevel>,
|
||||
/// The `SoundOutputLimiter` CVar cell the limiter reads each block.
|
||||
limiter_on: Arc<AtomicBool>,
|
||||
/// The device's negotiated sample rate — what turns a count of over-scale samples into a
|
||||
/// duration in the health report. `None` on a device we could not probe.
|
||||
sample_rate: Option<u32>,
|
||||
}
|
||||
|
||||
impl Mixer {
|
||||
@@ -133,14 +147,15 @@ impl Mixer {
|
||||
/// runs silent with `None` (mirrors the client's `-nosound` gate).
|
||||
pub(crate) fn new() -> Result<Self> {
|
||||
let (backend_settings, sample_rate) = backend_settings();
|
||||
let level = Arc::new(MixLevel::default());
|
||||
let limiter_on = Arc::new(AtomicBool::new(true));
|
||||
let settings = AudioManagerSettings::<DefaultBackend> {
|
||||
backend_settings,
|
||||
// The mix tap (decision 1112, `$WOW_MIX_TAP`) rides the main track as an effect —
|
||||
// a no-op builder unless the env var names a capture path.
|
||||
main_track_builder: super::mix_tap::install(
|
||||
kira::track::MainTrackBuilder::new(),
|
||||
sample_rate,
|
||||
),
|
||||
main_track_builder: main_track(&level, &limiter_on, sample_rate),
|
||||
capacities: kira::Capacities {
|
||||
sub_track_capacity: SPATIAL_VOICE_CAPACITY,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(settings)
|
||||
@@ -180,6 +195,9 @@ impl Mixer {
|
||||
health: MixHealth::default(),
|
||||
reverb_send,
|
||||
reverb,
|
||||
level,
|
||||
limiter_on,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,6 +257,22 @@ impl Mixer {
|
||||
.set_volume(amp_to_db(amp), glide());
|
||||
}
|
||||
|
||||
/// Arm or bypass the output limiter — the `SoundOutputLimiter` CVar (decision 1551). The
|
||||
/// limiter's delay line runs either way, so this is a fade, never a step in the output.
|
||||
pub(super) fn set_limiter(&mut self, on: bool) {
|
||||
self.limiter_on.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read and reset the mix's level for this report window (decision 1551).
|
||||
pub(super) fn take_level(&mut self) -> LevelReading {
|
||||
self.level.take()
|
||||
}
|
||||
|
||||
/// The device's negotiated sample rate, when we could probe it — the report's time axis.
|
||||
pub(super) fn sample_rate(&self) -> Option<u32> {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Play a decoded (short SFX) sound on the main track — the 2D/UI path.
|
||||
pub(crate) fn play_2d(&mut self, data: StaticSoundData) -> Result<StaticSoundHandle> {
|
||||
self.manager
|
||||
@@ -267,32 +301,46 @@ impl Mixer {
|
||||
) -> Result<(SpatialTrackHandle, StaticSoundHandle)> {
|
||||
// The zone wet level lives on the send's own volume, so per-zone reverb stays one knob
|
||||
// and never a per-track update; the per-KIT half is this route existing or not.
|
||||
let mut builder = SpatialTrackBuilder::new().attenuation_function(None);
|
||||
// One track, one sound, always — this is a fresh track per play. kira's default
|
||||
// `sound_capacity` is 128, and it is not free: the builder allocates two rtrb rings and an
|
||||
// arena *sized for 128 sounds* on every play, on the game thread, to hold exactly one
|
||||
// (`backend/resources.rs::ResourceStorage::new`). Naming the real number turns roughly ten
|
||||
// kilobytes of per-sound churn into a few dozen bytes.
|
||||
let mut builder = SpatialTrackBuilder::new()
|
||||
.attenuation_function(None)
|
||||
.sound_capacity(1);
|
||||
if reverb_send {
|
||||
builder = builder.with_send(&self.reverb_send, Decibels(0.0));
|
||||
}
|
||||
let mut track = self
|
||||
.manager
|
||||
.add_spatial_sub_track(
|
||||
self.listener.id(),
|
||||
mint::Vector3 {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
},
|
||||
builder
|
||||
// Outlive the handle by exactly as long as the sound needs (decision 1026).
|
||||
// kira defaults this to `false` (`track/sub/spatial_builder.rs`), which makes
|
||||
// `should_be_removed()` fire the instant the handle drops — so a channel that
|
||||
// is stopped-with-a-fade and then dropped in the same breath (every reaper
|
||||
// here: cutoff cull, despawn, leave-world) loses its track mid-ramp and gets
|
||||
// cut anyway. `declick()` would have been a no-op on all 3D audio. With this
|
||||
// set, the drop only *marks* the track and kira keeps it until its sounds
|
||||
// finish. Cannot leak: every path that drops a channel stops its sound first,
|
||||
// and a stopped sound finishes.
|
||||
.persist_until_sounds_finish(true),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("spatial track alloc: {e}"))?;
|
||||
let mut track = match self.manager.add_spatial_sub_track(
|
||||
self.listener.id(),
|
||||
mint::Vector3 {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
},
|
||||
builder
|
||||
// Outlive the handle by exactly as long as the sound needs (decision 1026).
|
||||
// kira defaults this to `false` (`track/sub/spatial_builder.rs`), which makes
|
||||
// `should_be_removed()` fire the instant the handle drops — so a channel that
|
||||
// is stopped-with-a-fade and then dropped in the same breath (every reaper
|
||||
// here: cutoff cull, despawn, leave-world) loses its track mid-ramp and gets
|
||||
// cut anyway. `declick()` would have been a no-op on all 3D audio. With this
|
||||
// set, the drop only *marks* the track and kira keeps it until its sounds
|
||||
// finish. Cannot leak: every path that drops a channel stops its sound first,
|
||||
// and a stopped sound finishes.
|
||||
.persist_until_sounds_finish(true),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// The voice ceiling, counted rather than only logged at the call site: a burst
|
||||
// that runs the arena dry drops sounds, and a dropped sound leaves no other trace.
|
||||
self.health.voices_refused += 1;
|
||||
return Err(anyhow::anyhow!(
|
||||
"spatial track alloc ({SPATIAL_VOICE_CAPACITY}-voice ceiling): {e}"
|
||||
));
|
||||
}
|
||||
};
|
||||
let handle = track
|
||||
.play(data)
|
||||
.map_err(|e| anyhow::anyhow!("play 3d: {e}"))?;
|
||||
@@ -312,6 +360,45 @@ impl Mixer {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many **3D voices** can be alive at once — the spatial sub-track arena (decision 1551).
|
||||
///
|
||||
/// Every positional one-shot gets its own spatial sub-track ([`Mixer::play_3d`]), so this is the
|
||||
/// hard ceiling on simultaneous 3D sound, and past it `add_spatial_sub_track` returns
|
||||
/// `ResourceLimitReached` and the sound is simply not heard.
|
||||
///
|
||||
/// kira's default is **128** (`manager/settings.rs`), which is a number we never chose and which
|
||||
/// a real fight reaches: `sound::combat` fires up to four kits per swing per attacker, and a kit
|
||||
/// like `HolyProtection` runs 3.4 s, so a pack pull holds many dozens of voices at a time. A
|
||||
/// ceiling that drops sounds is a *policy*, and the policy about which sounds may play belongs to
|
||||
/// the game — the reference's own per-bus caps (`0x87ce60`) — not to a backend arena size. So the
|
||||
/// arena is sized generously and explicitly, and [`MixHealth::voices_refused`] counts any refusal
|
||||
/// so that hitting even this is visible rather than silent.
|
||||
///
|
||||
/// The cost is preallocation, not per-frame work: an idle slot is a few hundred bytes and no DSP.
|
||||
const SPATIAL_VOICE_CAPACITY: usize = 512;
|
||||
|
||||
/// Build the **main track's effect chain**, in signal order (decision 1551):
|
||||
///
|
||||
/// 1. **meter** — measures the summed mix as the game asked for it, over-scale and all. Upstream
|
||||
/// of the limiter on purpose: the number worth reporting is what the mix *wanted*, because
|
||||
/// that is the one that says whether the game is asking for something impossible.
|
||||
/// 2. **limiter** — the brickwall that makes it fit, so kira's own hard clamp never fires.
|
||||
/// 3. **mix tap** — records what is actually heard (decision 1112, `$WOW_MIX_TAP`; a no-op
|
||||
/// builder unless the env var names a capture path).
|
||||
///
|
||||
/// A free function so the offline harness (`overlapping_kits_*`, below) can build the **same**
|
||||
/// chain over kira's mock backend — a headless proof of the audible claim, not a mirror of it.
|
||||
fn main_track(
|
||||
level: &Arc<MixLevel>,
|
||||
limiter_on: &Arc<AtomicBool>,
|
||||
sample_rate: Option<u32>,
|
||||
) -> kira::track::MainTrackBuilder {
|
||||
let mut main = kira::track::MainTrackBuilder::new();
|
||||
meter::install(&mut main, level);
|
||||
limiter::install(&mut main, level, limiter_on);
|
||||
super::mix_tap::install(main, sample_rate)
|
||||
}
|
||||
|
||||
/// The device buffer we ask for, in frames — the underrun margin (decision 1026).
|
||||
///
|
||||
/// kira's cpal backend takes `device.default_output_config().config()` when handed no config of
|
||||
@@ -388,6 +475,9 @@ pub(crate) struct MixHealth {
|
||||
pub(crate) overruns: u64,
|
||||
/// cpal stream errors kira handled, since launch (device loss, `BufferUnderrun`).
|
||||
pub(crate) stream_errors: u64,
|
||||
/// 3D plays refused because the spatial-voice arena was full ([`SPATIAL_VOICE_CAPACITY`]),
|
||||
/// since launch. Every one of these is a sound the player should have heard and did not.
|
||||
pub(crate) voices_refused: u64,
|
||||
}
|
||||
|
||||
impl Mixer {
|
||||
@@ -709,6 +799,169 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The **offline mix harness** (decision 1551): render N simultaneous copies of one real kit
|
||||
/// through the *same* main-track chain the client builds, over kira's mock backend, and
|
||||
/// report `(what the mix asked for, what came out)`.
|
||||
///
|
||||
/// This is the director's report reduced to arithmetic. No device, no game, no ear: kira's
|
||||
/// mock backend runs the real renderer on this thread, so the numbers are the numbers the
|
||||
/// audio callback would have produced.
|
||||
fn render_overlapping(bytes: &[u8], copies: usize, limiter: bool) -> (f32, f32, u64) {
|
||||
use kira::backend::mock::{MockBackend, MockBackendSettings};
|
||||
|
||||
const RATE: u32 = 44_100;
|
||||
let asked = Arc::new(MixLevel::default());
|
||||
let heard = Arc::new(MixLevel::default());
|
||||
let limiter_on = Arc::new(AtomicBool::new(limiter));
|
||||
// The client's own chain, plus one probe meter appended to read the limiter's output.
|
||||
let mut main = main_track(&asked, &limiter_on, Some(RATE));
|
||||
meter::install(&mut main, &heard);
|
||||
let mut manager = AudioManager::<MockBackend>::new(AudioManagerSettings {
|
||||
backend_settings: MockBackendSettings { sample_rate: RATE },
|
||||
main_track_builder: main,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("mock backend");
|
||||
|
||||
let data = sfx_from_bytes(bytes.to_vec()).expect("kit decodes");
|
||||
for _ in 0..copies {
|
||||
manager.play(data.clone()).expect("play");
|
||||
}
|
||||
// The whole kit, plus a beat: a buff sound swells, so its peak is nowhere near its first
|
||||
// millisecond, and a window that stops early measures silence and calls it headroom.
|
||||
let blocks = (data.duration().as_secs_f64() + 0.25) * f64::from(RATE) / 128.0;
|
||||
let backend = manager.backend_mut();
|
||||
for _ in 0..(blocks.ceil() as usize) {
|
||||
backend.on_start_processing();
|
||||
backend.process();
|
||||
}
|
||||
let heard = heard.take();
|
||||
let asked = asked.take().peak;
|
||||
// `--nocapture` turns the harness into a readout: the arithmetic of the reported defect.
|
||||
eprintln!(
|
||||
"mix harness: {copies} copies, limiter {} — asked {asked:.2}x, heard {:.2}x, \
|
||||
{} sample(s) past full scale",
|
||||
if limiter { "on " } else { "off" },
|
||||
heard.peak,
|
||||
heard.over,
|
||||
);
|
||||
(asked, heard.peak, heard.over)
|
||||
}
|
||||
|
||||
/// The **voice ceiling is real, and it is ours** (decision 1551): a spatial-track arena
|
||||
/// refuses past its capacity, and kira's unnamed default would have refused at 128 — a number
|
||||
/// a pack pull reaches, and one nobody here chose. Pins both halves so a kira upgrade that
|
||||
/// moves either cannot move benilla's voice ceiling silently.
|
||||
#[test]
|
||||
fn the_spatial_voice_arena_refuses_past_its_capacity() {
|
||||
use kira::backend::mock::{MockBackend, MockBackendSettings};
|
||||
|
||||
fn fill(capacity: usize) -> usize {
|
||||
let mut manager = AudioManager::<MockBackend>::new(AudioManagerSettings {
|
||||
backend_settings: MockBackendSettings {
|
||||
sample_rate: 44_100,
|
||||
},
|
||||
capacities: kira::Capacities {
|
||||
sub_track_capacity: capacity,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.expect("mock backend");
|
||||
let listener = manager
|
||||
.add_listener(
|
||||
mint::Vector3 {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
mint::Quaternion {
|
||||
v: mint::Vector3 {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
s: 1.0,
|
||||
},
|
||||
)
|
||||
.expect("listener");
|
||||
let mut held = Vec::new();
|
||||
for n in 0.. {
|
||||
let track = manager.add_spatial_sub_track(
|
||||
listener.id(),
|
||||
mint::Vector3 {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
SpatialTrackBuilder::new().sound_capacity(1),
|
||||
);
|
||||
match track {
|
||||
Ok(t) => held.push(t),
|
||||
Err(_) => return n,
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fill(kira::Capacities::default().sub_track_capacity),
|
||||
128,
|
||||
"kira's default voice ceiling moved — re-read the SPATIAL_VOICE_CAPACITY reasoning"
|
||||
);
|
||||
assert_eq!(fill(SPATIAL_VOICE_CAPACITY), SPATIAL_VOICE_CAPACITY);
|
||||
}
|
||||
|
||||
/// **The reported defect, reproduced and fixed, in numbers** (decision 1551).
|
||||
///
|
||||
/// `HolyProtection.wav` is the Fortitude buff — the director's own example ("a priest buffing
|
||||
/// a group with mass fort"). It is mastered to 1.000 peak and its kit (`SoundEntries` 3116)
|
||||
/// carries `Flags 0x0000`, so the no-duplicate bit does not gate it: a five-target cast
|
||||
/// starts five sample-aligned copies in one frame.
|
||||
///
|
||||
/// Bypassed, the mix asks for ~5x full scale and delivers it — which the renderer's
|
||||
/// `clamp(-1.0, 1.0)` turns into a squared-off waveform. Armed, the mix still *asks* for 5x
|
||||
/// (the game's own gain math is untouched) and nothing past the ceiling comes out.
|
||||
#[test]
|
||||
fn overlapping_kits_clip_without_the_limiter_and_do_not_with_it() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let Ok(chain) = benilla_formats::open_chain(&data) else {
|
||||
eprintln!("skipping: no client data at {}", data.display());
|
||||
return;
|
||||
};
|
||||
let bytes = chain
|
||||
.read("Sound\\Spells\\HolyProtection.wav")
|
||||
.expect("HolyProtection.wav in the chain");
|
||||
|
||||
// One copy is already sitting on full scale — that is the headroom problem, before any
|
||||
// overlap at all.
|
||||
let (asked, heard, over) = render_overlapping(&bytes, 1, false);
|
||||
assert!(
|
||||
asked > 0.99,
|
||||
"a single kit should already reach full scale, got {asked}"
|
||||
);
|
||||
assert!(over <= 2, "one copy should not meaningfully clip ({over})");
|
||||
assert!(heard <= 1.001);
|
||||
|
||||
// Five, bypassed: the reported case. The sum is ~5x and it comes out at ~5x.
|
||||
let (asked, heard, over) = render_overlapping(&bytes, 5, false);
|
||||
assert!(asked > 4.0, "five copies should sum to ~5x, got {asked}");
|
||||
assert!(
|
||||
heard > 4.0 && over > 1_000,
|
||||
"bypassed, the over-scale mix must reach the renderer's clamp \
|
||||
(peak {heard}, {over} samples over)"
|
||||
);
|
||||
|
||||
// Five, armed: the same request, held under the ceiling. Nothing clips.
|
||||
let (asked, heard, over) = render_overlapping(&bytes, 5, true);
|
||||
assert!(asked > 4.0, "the game still asks for ~5x, got {asked}");
|
||||
assert_eq!(over, 0, "the limiter let {over} samples past full scale");
|
||||
assert!(
|
||||
heard <= limiter::ceiling() + 1e-5,
|
||||
"output peaked at {heard}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The starvation accounting (decision 1109): a stream that advances in lockstep with wall
|
||||
/// time stays quiet; one whose position freezes mid-window (kira's zero-fill) reports the
|
||||
/// missing time; a slot swap's one-frame backward jump never reads as a freeze; and going
|
||||
|
||||
@@ -25,8 +25,10 @@ mod greeting;
|
||||
mod hal_overload;
|
||||
pub(crate) mod interior;
|
||||
mod kit;
|
||||
mod limiter;
|
||||
mod liquid_loop;
|
||||
mod math;
|
||||
mod meter;
|
||||
mod missile;
|
||||
mod mix_tap;
|
||||
mod mixer;
|
||||
@@ -118,6 +120,14 @@ pub(crate) struct SoundConfig {
|
||||
/// (1109) ride under the entry cover exactly as the real client's does: its amp is applied
|
||||
/// once at start, and a playing stream never passes back through the kit starters.
|
||||
pub world_hold: bool,
|
||||
/// The **output limiter** — benilla's own `SoundOutputLimiter` CVar (decision 1551), default
|
||||
/// **on**. Not a 1.12 CVar: the reference has no such DSP and does not need one, because it
|
||||
/// hands its whole audible mix to FMOD 3 and carries its headroom elsewhere (the SFX-bus
|
||||
/// auto-duck, wow-re `benilla-pins.md` B15). benilla sums into f32 and kira answers an
|
||||
/// over-scale sum with a hard clamp, which is audible distortion the moment two full-scale
|
||||
/// kits overlap — see [`limiter`] for the measured arithmetic. This exists so the fix can be
|
||||
/// A/B'd against what it fixed: `/run SetCVar("SoundOutputLimiter", 0)` applies live.
|
||||
pub limiter: bool,
|
||||
}
|
||||
|
||||
impl SoundConfig {
|
||||
@@ -148,6 +158,7 @@ impl Default for SoundConfig {
|
||||
ambience_enabled: true,
|
||||
reverb: false,
|
||||
world_hold: false,
|
||||
limiter: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,15 +351,20 @@ fn toggle_mute(keys: Res<ButtonInput<KeyCode>>, mut config: ResMut<SoundConfig>)
|
||||
fn apply_master_volume(
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
config: Res<SoundConfig>,
|
||||
mut last: Local<Option<(bool, f32)>>,
|
||||
mut last: Local<Option<(bool, f32, bool)>>,
|
||||
) {
|
||||
let cur = (config.enabled && !config.muted, config.master);
|
||||
let cur = (
|
||||
config.enabled && !config.muted,
|
||||
config.master,
|
||||
config.limiter,
|
||||
);
|
||||
if *last == Some(cur) {
|
||||
return;
|
||||
}
|
||||
*last = Some(cur);
|
||||
if let Some(mixer) = out.mixer.as_mut() {
|
||||
mixer.set_master(if cur.0 { cur.1 } else { 0.0 });
|
||||
mixer.set_limiter(cur.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +394,10 @@ fn poll_mix_health(
|
||||
mut exit: MessageReader<bevy::app::AppExit>,
|
||||
mut since_report: Local<std::time::Duration>,
|
||||
mut last_overruns: Local<u64>,
|
||||
mut last_refused: Local<u64>,
|
||||
mut peak_voices: Local<usize>,
|
||||
) {
|
||||
*peak_voices = (*peak_voices).max(out.channels.len());
|
||||
let Some(mixer) = out.mixer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -394,6 +413,9 @@ fn poll_mix_health(
|
||||
}
|
||||
*since_report = std::time::Duration::ZERO;
|
||||
let peak = mixer.take_health_peak();
|
||||
let level = mixer.take_level();
|
||||
let rate = mixer.sample_rate();
|
||||
let voices = std::mem::take(&mut *peak_voices);
|
||||
let new_overruns = health.overruns - *last_overruns;
|
||||
*last_overruns = health.overruns;
|
||||
if new_overruns > 0 {
|
||||
@@ -406,4 +428,47 @@ fn poll_mix_health(
|
||||
} else {
|
||||
debug!("audio: mix load peak {:.0}% of budget", peak * 100.0);
|
||||
}
|
||||
let new_refused = health.voices_refused - *last_refused;
|
||||
*last_refused = health.voices_refused;
|
||||
if new_refused > 0 {
|
||||
warn!(
|
||||
"audio: {new_refused} 3D sound(s) never played — the spatial-voice arena was full. \
|
||||
These are sounds the player should have heard; the ceiling is ours to raise \
|
||||
(`SPATIAL_VOICE_CAPACITY`), not the game's to work around.",
|
||||
);
|
||||
}
|
||||
report_level(level, voices, rate);
|
||||
}
|
||||
|
||||
/// The level half of the report (decision 1551) — the amplitude story none of the timing meters
|
||||
/// can tell. A mix that asks for more than full scale is not a maybe either: the sum did not fit,
|
||||
/// and without the limiter kira's `clamp` would have squared it off. The line names what the game
|
||||
/// asked for, how long it was over, what the limiter had to pull, and how many voices were live —
|
||||
/// which together say *why* (thirty voices at once is a different bug from one voice at 4×).
|
||||
fn report_level(level: meter::LevelReading, voices: usize, rate: Option<u32>) {
|
||||
if level.over == 0 {
|
||||
debug!(
|
||||
"audio: mix peak {:.2} of full scale, {voices} voice(s) at most",
|
||||
level.peak,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Two samples per frame; an unprobeable device leaves the duration out rather than guessing
|
||||
// a time axis (the same rule the mix tap follows).
|
||||
let over = match rate {
|
||||
Some(r) => format!(
|
||||
"for ~{:.0} ms",
|
||||
level.over as f64 / 2.0 / f64::from(r) * 1000.0
|
||||
),
|
||||
None => format!("across {} samples", level.over),
|
||||
};
|
||||
warn!(
|
||||
"audio: the mix asked for {:.2}x full scale ({:+.1} dBFS) {over} of the last {}s, with \
|
||||
{voices} voice(s) live at most; the limiter pulled up to {:.1} dB to hold it under. \
|
||||
Without it that is hard clipping — the \"dirty, like a speaker breaking\" report.",
|
||||
level.peak,
|
||||
20.0 * level.peak.max(1e-6).log10(),
|
||||
MIX_HEALTH_REPORT.as_secs(),
|
||||
20.0 * level.reduction.max(1e-6).log10(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
//! change forces the next-start time to −1 (`0x4602e0`: `DAT_00836400 = 0xffffffff`), so `0x460040`
|
||||
//! opens the stream that tick (`0x460240`). The randomized silence interval ([`next_track_time`],
|
||||
//! `0x4601f0`) is ONLY the *same-zone* track-to-track spacing — reusing it on a change (as decision
|
||||
//! 0100 did) left a newly-entered zone silent for 3–5 min. Cold start (session's first track) waits
|
||||
//! 6 s. The incoming track starts at **full** volume, **no fade-in** — faithful (`0x460240` →
|
||||
//! 0100 did) left a newly-entered zone silent for 3–5 min. **There is no cold start**: world entry
|
||||
//! itself arms the same −1 (`0x401570` → `0x457770` → the zone-music init `0x45ffc0`, which sets
|
||||
//! `DAT_00836400 = -1` at `0x45ffdb`), so the session's FIRST track is immediate too. `0x4601f0`'s
|
||||
//! `== 0 → now + 6000 ms` branch is reachable only from the natural-end reap `0x4600b6` and only
|
||||
//! ever *replaces* the −1, so it cannot fire on an entry — the 6 s wait benilla used to serve there
|
||||
//! was that branch misapplied (wow-re §5 `glue-music-world-entry.md` §9, decision 1553). The incoming track starts at **full** volume, **no fade-in** — faithful (`0x460240` →
|
||||
//! `0x7a5dc0`; §5 B16 refuted every music-slot fade-in primitive, and the director confirmed by ear
|
||||
//! there is none). The reference's *perceived* slightly-soft onset is the delegated audio engine
|
||||
//! priming the stream to full amplitude over ~a moment (FMOD there, kira's own stream-buffering
|
||||
@@ -160,10 +164,6 @@ pub(super) struct ZoneAudio {
|
||||
/// When the next zone track starts (Time::elapsed_secs_f64; None = a track is playing or
|
||||
/// no music in this zone).
|
||||
next_track_at: Option<f64>,
|
||||
/// Whether a music track has ever started this session — the client's cold-start gate
|
||||
/// (`0x4601f0`: `DAT_00b06cbc == 0` → the first track waits 6 s; afterwards the incoming
|
||||
/// track follows the randomized per-phase silence interval).
|
||||
have_played_music: bool,
|
||||
/// The looping ambience kit currently up (0 = none) + its handle and base volume. A static
|
||||
/// loop, not a stream ([`mixer::loop_from_bytes`] — streaming loop beds die at EOF).
|
||||
ambience_kit: u32,
|
||||
@@ -192,7 +192,6 @@ impl Default for ZoneAudio {
|
||||
music_kit: 0,
|
||||
music_kit_vol: 1.0,
|
||||
next_track_at: None,
|
||||
have_played_music: false,
|
||||
ambience_kit: 0,
|
||||
ambience: None,
|
||||
ambience_kit_vol: 1.0,
|
||||
@@ -343,19 +342,16 @@ fn zone_audio(
|
||||
slot_taken = true;
|
||||
}
|
||||
}
|
||||
// Otherwise the incoming zone track starts NOW, at full volume. The client starts it
|
||||
// immediately on a zone-music change — `0x4602e0` forces the next-start time to −1
|
||||
// (`DAT_00836400 = 0xffffffff`), so `0x460040` opens the stream that tick (`0x460240`)
|
||||
// at full; the randomized silence gap ([`next_track_time`]) is only the *same-zone*
|
||||
// track-to-track spacing. Decision 0100 wrongly reused that gap here, which left a
|
||||
// newly-entered zone (an inn's tavern music) silent for 3–5 min. Exception: the
|
||||
// session's very first track waits the 6 s cold-start.
|
||||
// Otherwise the incoming zone track starts NOW, at full volume — with no exception.
|
||||
// The client starts it immediately on a zone-music change (`0x4602e0` forces the
|
||||
// next-start time to −1, so `0x460040` opens the stream that tick at full) AND on a
|
||||
// fresh world entry (`0x45ffc0` arms the same −1). The randomized silence gap
|
||||
// ([`next_track_time`]) is only the *same-zone* track-to-track spacing: decision 0100
|
||||
// wrongly reused it on a change, leaving a newly-entered zone silent for 3–5 min, and
|
||||
// the 6 s "cold start" that used to sit here was `0x4601f0`'s unreachable arm applied
|
||||
// to an entry that never calls it (1553).
|
||||
if !slot_taken {
|
||||
if !zone.have_played_music {
|
||||
zone.next_track_at = Some(now + 6.0);
|
||||
} else if let Some(kit) =
|
||||
zone_music_row(&areas.0, music_row).map(|m| m.sounds[phase])
|
||||
{
|
||||
if let Some(kit) = zone_music_row(&areas.0, music_row).map(|m| m.sounds[phase]) {
|
||||
if kit != 0 {
|
||||
start_music_stream(zone, &mut out, &mut kits, &assets, &config, kit);
|
||||
}
|
||||
@@ -472,11 +468,12 @@ fn zone_music_row(cat: &AreaSoundCatalog, _id: u32) -> Option<&benilla_formats::
|
||||
cat.zone_music(_id)
|
||||
}
|
||||
|
||||
/// When the incoming track should start, the client's schedule (`0x4601f0`): `None` if the new
|
||||
/// zone has no music; `now + 6 s` for the very first track of the session (cold start,
|
||||
/// `DAT_00b06cbc == 0`); otherwise `now +` the new row's randomized per-phase silence interval.
|
||||
/// (`SoundZoneMusicNoDelay`, the immediate path, is a "0" CVar we don't expose.) Shared by the
|
||||
/// zone-change transition and the end-of-track reap so the two can't drift.
|
||||
/// When the NEXT track should start after this one ends — the client's `0x4601f0`, whose sole
|
||||
/// caller is the natural-end reap `0x4600b6`: `None` if the zone has no music; otherwise `now +`
|
||||
/// the row's randomized per-phase silence interval. (`SoundZoneMusicNoDelay`, the immediate path,
|
||||
/// is a "0" CVar we don't expose.) **Not a cold start** — `0x4601f0`'s `== 0 → now + 6000 ms` arm
|
||||
/// needs a *cleared* currently-playing row, which end-of-track flow never presents, and an entry
|
||||
/// never reaches this function at all (it takes the −1 "start now" path; module docs, 1553).
|
||||
fn next_track_time(
|
||||
zone: &mut ZoneAudio,
|
||||
cat: &AreaSoundCatalog,
|
||||
@@ -487,9 +484,6 @@ fn next_track_time(
|
||||
if music_row == 0 {
|
||||
return None;
|
||||
}
|
||||
if !zone.have_played_music {
|
||||
return Some(now + 6.0);
|
||||
}
|
||||
// Read the min/max out from under the catalog borrow before the rng draw (which needs `zone`).
|
||||
let interval =
|
||||
zone_music_row(cat, music_row).map(|m| (m.silence_min[phase], m.silence_max[phase]));
|
||||
@@ -550,7 +544,6 @@ fn start_music_stream(
|
||||
}
|
||||
zone.music_kit = kit_id;
|
||||
zone.music_kit_vol = kit_vol;
|
||||
zone.have_played_music = true;
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -696,9 +689,8 @@ fn server_sounds(
|
||||
/// not a zone change: the beds hard-stop on a short declick ramp (NOT the 4.0 s / 5.0 s musical
|
||||
/// transitions — the real client destroys the world's sound state outright and the glue theme
|
||||
/// takes over), and the scheduler resets to cold so the next login resolves fresh from its own
|
||||
/// area. Deliberately kept: `have_played_music`, `intro_last`, and the RNG — their client
|
||||
/// counterparts are process-global statics, not per-session state (the 6 s cold-start and the
|
||||
/// intro throttle survive a relog).
|
||||
/// area. Deliberately kept: `intro_last` and the RNG — their client counterparts are process-global
|
||||
/// statics, not per-session state (the intro throttle survives a relog).
|
||||
fn leave_world(mut zone: NonSendMut<ZoneAudio>) {
|
||||
stop_world_soundscape(&mut zone, "left world");
|
||||
}
|
||||
|
||||
@@ -775,6 +775,23 @@ pub(crate) fn can_attack_from_player(
|
||||
}
|
||||
}
|
||||
|
||||
/// The one number `CanCooperate` turns on: a unit's `FactionTemplate` **faction-group mask**
|
||||
/// (`row + 0xc`) — 3 for every Alliance race row, 5 for every Horde one, and something else
|
||||
/// entirely for a row that is neither. Exposed for `/reaction`, which prints both sides of the
|
||||
/// comparison: when a player's plate lands in the wrong bucket this is nearly always the reason,
|
||||
/// and it is not visible anywhere else in the client.
|
||||
pub(crate) fn faction_group_mask(
|
||||
factions: Option<&Factions>,
|
||||
store: Option<&ObjectStore>,
|
||||
) -> Option<u32> {
|
||||
let catalog = &factions?.0;
|
||||
Some(
|
||||
catalog
|
||||
.template(store?.0.unit_faction_template()?)?
|
||||
.group_mask,
|
||||
)
|
||||
}
|
||||
|
||||
/// `CanCooperate(this = the local player → arg = unit)` — `0x606ba0`, byte-verified (wow-re
|
||||
/// `nameplate-category-gate.md` §2a): the two `FactionTemplate` rows' **faction-group masks**
|
||||
/// (`row + 0xc`) being equal, with neither side mind-controlled and the two not being the same
|
||||
@@ -788,6 +805,13 @@ pub(crate) fn can_cooperate_with_player(
|
||||
let (Some(target), Some(own)) = (target_store, self_store) else {
|
||||
return false;
|
||||
};
|
||||
// `606ba6 cmp B,A ; je -> 0` — the predicate's FIRST leg: a unit never cooperates with
|
||||
// itself. The reference compares object pointers, so ours compares component identity. The
|
||||
// plate gate can't reach it (its query is `Without<SelfPlayer>`), but the predicate is
|
||||
// transcribed law and the leg was missing from it.
|
||||
if std::ptr::eq(target, own) {
|
||||
return false;
|
||||
}
|
||||
if target.0.unit_charmed_by().is_some_and(|g| g != 0)
|
||||
|| own.0.unit_charmed_by().is_some_and(|g| g != 0)
|
||||
{
|
||||
@@ -896,8 +920,8 @@ pub(crate) fn duel_rung(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
plate_is_friendly, ring_reaction, ring_variant, Factions, RingVariant,
|
||||
UNIT_FLAG_PVP_ATTACKABLE,
|
||||
can_attack_from_player, can_cooperate_with_player, plate_is_friendly, ring_reaction,
|
||||
ring_variant, Factions, RingVariant, UNIT_FLAG_PVP_ATTACKABLE,
|
||||
};
|
||||
|
||||
/// The selector's player path — the `¬X∧¬Y` split the party arc added (0434 phase 6). A
|
||||
@@ -1022,6 +1046,87 @@ mod tests {
|
||||
assert_eq!(rank(&unit(12), &quiet), 4);
|
||||
}
|
||||
|
||||
/// **The PLAYER subject** — the arm 1530 landed and left unpinned, on the real DBC.
|
||||
///
|
||||
/// A player is friendly-category only if `CanCooperate` ALSO says yes, and that predicate is
|
||||
/// pure `FactionTemplate` faction-group-mask equality (§2a). So the arm turns entirely on one
|
||||
/// number — `group_mask` 3 for every Alliance race template, 5 for every Horde one — and the
|
||||
/// interesting population is the templates that carry NEITHER.
|
||||
///
|
||||
/// **`35` is that template, and it is what a vmangos test realm hands you**: `.gm on` calls
|
||||
/// `Player::SetGameMaster(true)` → `SetFactionTemplateId(35)` (vmangos `Player.cpp:2656`), whose
|
||||
/// row is `faction 31, group_mask 0`. Zero equals no player's mask, so a GM-mode character —
|
||||
/// on either side of the pair — falls out of the friendly bucket and takes a plate under plain
|
||||
/// V. Nothing else on screen moves, because `CanCooperate` is only ever consulted for a player
|
||||
/// subject. That asymmetric signature is what a "players are plating and mobs look fine"
|
||||
/// report means, and this test is where to read it. (`GM.LoginState = 2` keeps the mode across
|
||||
/// logins, so it outlives the session that set it.)
|
||||
#[test]
|
||||
fn the_player_subject_category_on_the_real_dbc() {
|
||||
use crate::net::{ObjectStore, Reputations};
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let factions = Factions(benilla_formats::load_faction_catalog(&mut chain).expect("dbc"));
|
||||
let reps = Reputations::default();
|
||||
// Every player carries `PLAYER_CONTROLLED`; the plate gate passes `is_player = true`.
|
||||
let player = |tpl: u32| {
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(35, tpl),
|
||||
(46, UNIT_FLAG_PVP_ATTACKABLE),
|
||||
]))
|
||||
};
|
||||
let category = |subject: &ObjectStore, me: &ObjectStore| {
|
||||
plate_is_friendly(Some(&factions), &reps, Some(subject), Some(me), true)
|
||||
};
|
||||
|
||||
// Us: a Human (FT 1, group_mask 3).
|
||||
let me = player(1);
|
||||
// Same faction group — a Gnome is FT 115, a different row with the SAME mask. Friendly
|
||||
// category: no plate under plain V, which is the shipped behaviour this pins.
|
||||
assert!(
|
||||
category(&player(115), &me),
|
||||
"an Alliance player is friendly"
|
||||
);
|
||||
assert!(category(&player(1), &me), "…same race, same answer");
|
||||
// The other faction group (Orc FT 2, mask 5) is enemy-category — and that is FAITHFUL:
|
||||
// the reference plates the opposing faction under plain V, flagged or not.
|
||||
assert!(!category(&player(2), &me), "a Horde player is enemy");
|
||||
// A GM-mode player (FT 35, mask 0) matches NOBODY's mask — enemy category from a normal
|
||||
// character, while `CanAttack` still refuses (the plate is drawn over someone unattackable).
|
||||
assert!(!category(&player(35), &me), "a GM-mode player is enemy");
|
||||
assert!(
|
||||
!can_attack_from_player(Some(&factions), &reps, Some(&player(35)), Some(&me), true),
|
||||
"…and not because we can attack them",
|
||||
);
|
||||
|
||||
// The mirror: a GM-mode OBSERVER loses the friendly bucket for every player at once…
|
||||
let gm = player(35);
|
||||
for tpl in [1u32, 3, 4, 115, 2, 5, 6, 116] {
|
||||
assert!(
|
||||
!category(&player(tpl), &gm),
|
||||
"FT {tpl} is enemy-category to a GM-mode observer",
|
||||
);
|
||||
}
|
||||
// …while nothing else on screen moves: `CanCooperate` is never consulted for an NPC, so a
|
||||
// Stormwind guard stays friendly and a Monster-faction mob stays enemy. THAT asymmetry is
|
||||
// the report's signature.
|
||||
let npc = |tpl: u32| ObjectStore(ObjectFields::from_pairs(&[(35, tpl)]));
|
||||
let npc_category = |subject: &ObjectStore, me: &ObjectStore| {
|
||||
plate_is_friendly(Some(&factions), &reps, Some(subject), Some(me), false)
|
||||
};
|
||||
assert!(npc_category(&npc(12), &gm), "the guard keeps his bucket");
|
||||
assert!(!npc_category(&npc(14), &gm), "and the mob keeps his");
|
||||
|
||||
// The predicate's first leg (§2a `606ba6`): nobody cooperates with themselves.
|
||||
assert!(!can_cooperate_with_player(
|
||||
Some(&factions),
|
||||
Some(&me),
|
||||
Some(&me)
|
||||
));
|
||||
}
|
||||
|
||||
/// `CanAttack`'s flag disqualifiers put a unit in the FRIENDLY bucket **at any reaction** — the
|
||||
/// class of case a rank threshold gets wrong in the other direction (wow-re §3b). Asserted on
|
||||
/// the mask path (a Monster-faction template, hostile by reaction) so the reaction is
|
||||
|
||||
@@ -312,41 +312,58 @@ fn send_spell_cast(
|
||||
// (`6e4ef4` → `0x612df0` over the guid pair it was PASSED) takes an explicit target: the key
|
||||
// chain calls `CGItem::Use` with the lock's guid, the bag click with zero (decision 0769).
|
||||
let mut pending_word = None;
|
||||
let mut item_target = None;
|
||||
let explicit_object = match commit {
|
||||
CastCommit::Item { on_object, .. } => on_object,
|
||||
CastCommit::Spell => None,
|
||||
};
|
||||
let candidates = cast_target::CastCandidates {
|
||||
selection: ctx.selection_guid,
|
||||
caster: ctx.self_guid,
|
||||
// The ref resolves its candidate guid through `0x468460(typemask 1)` (`6e53bc`) before
|
||||
// handing it to the binder: a guid naming no live object binds nothing. Ours is the item
|
||||
// cache — an equipped item whose object has not streamed yet is exactly that miss, and
|
||||
// falls to the cursor rather than shipping a guid the server would reject.
|
||||
main_hand_item: ctx
|
||||
.main_hand_item
|
||||
.filter(|guid| items.object(*guid).is_some()),
|
||||
};
|
||||
let target = match explicit_object {
|
||||
Some(_) => None,
|
||||
None => match cast_target::resolve_cast_target(
|
||||
def,
|
||||
ctx.selection_guid,
|
||||
ctx.self_guid,
|
||||
ctx.auto_self_cast,
|
||||
&ctx.rel,
|
||||
) {
|
||||
cast_target::CastWireTarget::SelfImplicit => None,
|
||||
cast_target::CastWireTarget::Unit(guid) => Some(guid),
|
||||
cast_target::CastWireTarget::Targeting(word) => {
|
||||
// The cursor ENTRY is deferred below the validator rungs (decision 0948: the
|
||||
// ref enters targeting at cast-arm `6e50c8`, AFTER the validator `0x6094f0` —
|
||||
// an on-cooldown or unaffordable press refuses before the cursor ever comes
|
||||
// up). The word parks here; nothing is sent, nothing armed either way. The
|
||||
// COMMIT rides the word: the ref keeps the whole pending-cast block (the item
|
||||
// guid at `0xceac48` included) across the cursor, so the click's `0x6e54f0`
|
||||
// still emits USE_ITEM for a grenade / poison / key and CAST_SPELL for an
|
||||
// enchant or opener. ONE arm, not one per seam (decisions 0792 / 0923 / 0939).
|
||||
pending_word = Some(word);
|
||||
None
|
||||
}
|
||||
cast_target::CastWireTarget::Refused(reason) => {
|
||||
debug!(
|
||||
None => {
|
||||
match cast_target::resolve_cast_target(def, &candidates, ctx.auto_self_cast, &ctx.rel) {
|
||||
cast_target::CastWireTarget::SelfImplicit => None,
|
||||
cast_target::CastWireTarget::Unit(guid) => Some(guid),
|
||||
// The main-hand auto-pick (decision 1552): `BindTarget`'s item arm ran at cast-arm
|
||||
// time, so this press already has its target — it rides the rest of the validator
|
||||
// ladder like any bound cast and lands in the commit's ITEM shape below. It is NOT a
|
||||
// unit, so the range gate below skips it exactly as the ref does (`0x6e47b0` guards
|
||||
// the `SPELLCAST+0x14 & 0x8202` unit binds only).
|
||||
cast_target::CastWireTarget::Item(guid) => {
|
||||
item_target = Some(guid);
|
||||
None
|
||||
}
|
||||
cast_target::CastWireTarget::Targeting(word) => {
|
||||
// The cursor ENTRY is deferred below the validator rungs (decision 0948: the
|
||||
// ref enters targeting at cast-arm `6e50c8`, AFTER the validator `0x6094f0` —
|
||||
// an on-cooldown or unaffordable press refuses before the cursor ever comes
|
||||
// up). The word parks here; nothing is sent, nothing armed either way. The
|
||||
// COMMIT rides the word: the ref keeps the whole pending-cast block (the item
|
||||
// guid at `0xceac48` included) across the cursor, so the click's `0x6e54f0`
|
||||
// still emits USE_ITEM for a grenade / poison / key and CAST_SPELL for an
|
||||
// enchant or opener. ONE arm, not one per seam (decisions 0792 / 0923 / 0939).
|
||||
pending_word = Some(word);
|
||||
None
|
||||
}
|
||||
cast_target::CastWireTarget::Refused(reason) => {
|
||||
debug!(
|
||||
"ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})"
|
||||
);
|
||||
cast_errors.push_local(spell_id, reason);
|
||||
return;
|
||||
cast_errors.push_local(spell_id, reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
// The local range gate (the client's TryCast runs `CanTargetUnit 0x6e4440` →
|
||||
// `IsTargetInRange 0x6e47b0` BEFORE the commit `0x6e54f0`): an out-of-range / too-close
|
||||
@@ -568,7 +585,15 @@ fn send_spell_cast(
|
||||
}
|
||||
// The commit's ONE branch (`SendCast 0x6e54f0`): same block, two opcodes.
|
||||
let _ = commands.0.send(match commit {
|
||||
CastCommit::Spell => ClientCommand::CastSpell { spell_id, target },
|
||||
CastCommit::Spell => match item_target {
|
||||
// `SendCast 0x6e54f0`'s item leg — the same block the bag click's commit reaches, just
|
||||
// arrived at without a click (decision 1552).
|
||||
Some(item_guid) => ClientCommand::CastSpellItem {
|
||||
spell_id,
|
||||
item_guid,
|
||||
},
|
||||
None => ClientCommand::CastSpell { spell_id, target },
|
||||
},
|
||||
CastCommit::Item {
|
||||
bag_index,
|
||||
slot,
|
||||
@@ -579,10 +604,11 @@ fn send_spell_cast(
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
target: match (on_object, target) {
|
||||
(Some(go), _) => UseItemTarget::Object(go),
|
||||
(None, Some(unit)) => UseItemTarget::Unit(unit),
|
||||
(None, None) => UseItemTarget::SelfImplicit,
|
||||
target: match (on_object, item_target, target) {
|
||||
(Some(go), _, _) => UseItemTarget::Object(go),
|
||||
(None, Some(item), _) => UseItemTarget::Item(item),
|
||||
(None, None, Some(unit)) => UseItemTarget::Unit(unit),
|
||||
(None, None, None) => UseItemTarget::SelfImplicit,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -682,6 +708,7 @@ mod tests {
|
||||
reputations: &EMPTY_REPUTATIONS,
|
||||
},
|
||||
range: cast_target::RangeInputs::default(),
|
||||
main_hand_item: None,
|
||||
self_move_flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
//! the matching object-layer relation (assist `0x6066f0` / attack `0x606980` / corpse `0x6067d0`),
|
||||
//! binds the guid and clears the bit; only a fully-cleared word commits (wire mask
|
||||
//! `TARGET_FLAG_UNIT (0x2)` + the bound guid).
|
||||
//! - **`Attributes & 0x200`** ⇒ the candidate is not the selection at all but the caster's
|
||||
//! **equipped main hand** (`6e5361`–`6e5391`): the client picks the weapon itself and binds it
|
||||
//! as an ITEM target, so a shaman's Rockbiter / Flametongue / Frostbrand / Windfury Weapon —
|
||||
//! 22 rows, four names, the whole 5875 family — casts straight onto the weapon with no cursor
|
||||
//! and no click. Decision 1552.
|
||||
//! - a candidate that satisfies nothing falls back to the **active player** — gated on the
|
||||
//! `autoSelfCast` CVar (name `0x870dc0`, gate `[0xceac34]+0x28` at `0x6e53d7`; registered with
|
||||
//! engine default `"0"`). The classic "buffing with an enemy targeted casts on yourself".
|
||||
@@ -88,6 +93,10 @@ const TF_LOCKED: u16 = 0x4000;
|
||||
/// the one implicit arm 23 ORs in ([`cast_target_mask`]). A word carrying it arms the world click's
|
||||
/// GameObject leg (decision 0939).
|
||||
const TF_GAMEOBJECT: u16 = 0x0800;
|
||||
/// The bits `BindTarget 0x6e5b40`'s **item** arm admits and then clears — `6e5f1e`'s
|
||||
/// `testl $0x4010` and `6e5f36`'s `and $0xbfef` (bits 4 and 14). The same `0x4010` that
|
||||
/// `TargetingWantsItem 0x6e6330` asks the standing word; here it is the *bind* side of it.
|
||||
const ITEM_ARM_BITS: u16 = TF_ITEM | TF_LOCKED;
|
||||
|
||||
/// What the wire's target block should carry for this cast.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -97,6 +106,12 @@ pub(crate) enum CastWireTarget {
|
||||
/// A bound unit — mask `TARGET_FLAG_UNIT (0x2)` + this guid (possibly the player's own,
|
||||
/// via the autoSelfCast fallback).
|
||||
Unit(u64),
|
||||
/// A bound **item** — mask `TARGET_FLAG_ITEM (0x10)` + this item guid, sent on the press with
|
||||
/// no cursor in between. `BindTarget 0x6e5b40`'s non-unit item arm (`6e5f15`ff) reached
|
||||
/// straight from the cast-arm, which is the shape only the main-hand auto-pick below produces
|
||||
/// ([`CastCandidates::main_hand_item`]): every *other* item target in the game arrives through
|
||||
/// the targeting cursor's click seam instead ([`super::targeting`]).
|
||||
Item(u64),
|
||||
/// The cast awaits a click — no send yet: enter the targeting-cursor mode with **this standing
|
||||
/// flag_word** ([`super::targeting`]), which is the reference's own state (`0xcecac0` != 0 *is*
|
||||
/// `IsTargeting 0x6e48a0`). Each click seam then asks the word its own question, so one word
|
||||
@@ -109,6 +124,27 @@ pub(crate) enum CastWireTarget {
|
||||
Refused(u8),
|
||||
}
|
||||
|
||||
/// The three places `ArmCast 0x6e5250` can take its candidate guid from (`6e5361`–`6e539f`),
|
||||
/// in the reference's own priority order. Named rather than positional because they are three
|
||||
/// interchangeable `Option<u64>`s and a swap between them is silent.
|
||||
///
|
||||
/// The reference resolves **one** of these, not a sequence: the main-hand leg is taken *instead
|
||||
/// of* the selection, never before it. (Our walk still tries `caster` after `selection` — that is
|
||||
/// the autoSelfCast fallback at `6e53d7`, a separate rung further down.)
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct CastCandidates {
|
||||
/// The current selection — the ref's global `0xb4e2d8` (`6e539f`), and the explicit-guid
|
||||
/// argument that precedes it (`6e5393`; every benilla caster passes zero there).
|
||||
pub(crate) selection: Option<u64>,
|
||||
/// The active player, for the autoSelfCast fallback (`6e53d7`).
|
||||
pub(crate) caster: Option<u64>,
|
||||
/// The equipped main hand's item guid — `[player+0x1d3c][15]`, filtered to a guid that
|
||||
/// actually resolves to a live object (the ref's `0x468460(typemask 1)` at `6e53bc`).
|
||||
/// `None` when the slot is empty or the item has not streamed, which is the ref's
|
||||
/// esi:edi = 0 and ends in the item cursor exactly as it does here.
|
||||
pub(crate) main_hand_item: Option<u64>,
|
||||
}
|
||||
|
||||
/// Everything the binder's relation checks read. Both call sites (action bar, spellbook) bundle
|
||||
/// the same resources; the stores are the *selected* unit's and the player's.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -129,6 +165,11 @@ pub(crate) struct CastContext<'a> {
|
||||
/// The local range gate's inputs (`IsTargetInRange 0x6e47b0` over `GetMinMaxRange
|
||||
/// 0x6e3480`) — the caster's and the selection's position + combat reach.
|
||||
pub(crate) range: RangeInputs,
|
||||
/// The equipped main hand's item guid, straight off the player descriptor —
|
||||
/// `PLAYER_FIELD_INV_SLOT_HEAD + 2*15`, the client's `[player+0x1d3c][15]`. `None` when the
|
||||
/// slot is empty. The *liveness* filter (does the guid name a streamed item object?) is the
|
||||
/// send path's, where the item cache is in scope.
|
||||
pub(crate) main_hand_item: Option<u64>,
|
||||
/// The caster's live CMovement flags word (the client's `[unit+0x9e8]`,
|
||||
/// [`crate::creature_anim::move_flags`] layout) — what the requirement validator's moving
|
||||
/// gate reads at cast initiation.
|
||||
@@ -203,11 +244,21 @@ impl CastTargeting<'_, '_> {
|
||||
.map_or(1.5, |s| s.0.unit_combat_reach()),
|
||||
target_reach: target_store.map(|s| s.0.unit_combat_reach()),
|
||||
},
|
||||
main_hand_item: self
|
||||
.self_store
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|s| s.0.player_inv_slot(EQUIPMENT_SLOT_MAINHAND))
|
||||
.filter(|&g| g != 0),
|
||||
self_move_flags: self.player.move_flags(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Equipment slot 15 — `EQUIPMENT_SLOT_MAINHAND`. The client reaches it as byte offset `0x78`
|
||||
/// into the player's inventory guid table (`6e538b: mov esi,[ecx+0x78]`, `0x78 == 15 * 8`).
|
||||
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
|
||||
|
||||
/// The `autoSelfCast` knob (the ref's CVar, default `"0"`). benilla defaults it **on** — a named
|
||||
/// deviation: with it off, an unbindable friendly cast falls into the ref's targeting-cursor
|
||||
/// machine, which benilla doesn't model yet, leaving no path at all. Flip the default to the
|
||||
@@ -309,13 +360,12 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
|
||||
/// self-implicit without one (the server still validates).
|
||||
pub(crate) fn resolve_cast_target(
|
||||
def: Option<&SpellDisplay>,
|
||||
selection_guid: Option<u64>,
|
||||
self_guid: Option<u64>,
|
||||
cand: &CastCandidates,
|
||||
auto_self_cast: bool,
|
||||
rel: &TargetRelations,
|
||||
) -> CastWireTarget {
|
||||
let Some(def) = def else {
|
||||
return match selection_guid {
|
||||
return match cand.selection {
|
||||
Some(guid) => CastWireTarget::Unit(guid),
|
||||
None => CastWireTarget::SelfImplicit,
|
||||
};
|
||||
@@ -335,6 +385,31 @@ pub(crate) fn resolve_cast_target(
|
||||
// them agreeing if one ever doesn't.
|
||||
return CastWireTarget::Targeting(word | TF_DEST_LOCATION);
|
||||
}
|
||||
// ArmCast's FIRST candidate leg (`6e5361`–`6e5391`, decision 1552) — the one that makes a
|
||||
// weapon imbue cast-and-done. A player caster pressing a spell with `Attributes & 0x200`
|
||||
// takes its candidate from the equipped **main hand** (`[player+0x1d3c][15]`) rather than
|
||||
// from the explicit guid or the selection, and hands it straight to `BindTarget 0x6e5b40`.
|
||||
// The caster-is-a-player test at `6e5361` is structural here: this resolver only ever runs
|
||||
// for the local player's own press.
|
||||
//
|
||||
// It must sit ABOVE the non-unit defer below, which is where every item word currently ends
|
||||
// up. That defer's premise — "no candidate can clear these bits" — is true only of *unit*
|
||||
// candidates; an item candidate clears bit 4, which is precisely why Rockbiter and Windfury
|
||||
// Weapon were raising a cursor over a weapon the client already knew how to pick.
|
||||
if def.targets_main_hand_item() {
|
||||
if let Some(item) = cand.main_hand_item {
|
||||
// `BindTarget`'s item arm: `6e5f1e: testl $0x4010` admits it, `6e5f2e` ORs
|
||||
// `TARGET_FLAG_ITEM` into the wire mask, `6e5f36` clears word bits 4 and 14. A word
|
||||
// that clears to zero commits on the spot (`6e60c1` → `SendCast 0x6e54f0`).
|
||||
if word & ITEM_ARM_BITS != 0 && word & !ITEM_ARM_BITS == 0 {
|
||||
return CastWireTarget::Item(item);
|
||||
}
|
||||
}
|
||||
// No main hand — or a residual the item arm can't discharge (no shipped row: all 22
|
||||
// attribute-bearing rows are `Targets == 0x10` exactly, censused in
|
||||
// `benilla-formats`). The ref's bind fails, the word stands, and the press ends in the
|
||||
// item cursor: the defer below. The selection is never consulted for these.
|
||||
}
|
||||
// Bits outside the unit family (item/gameobject/location/string) have no candidate here.
|
||||
// The DEST-location word (Blizzard's bare `Targets = 0x40`, default switch arm) is the
|
||||
// targeting cursor's location half (decision 0792) — in the ref it falls out of the failed
|
||||
@@ -363,17 +438,18 @@ pub(crate) fn resolve_cast_target(
|
||||
}
|
||||
return CastWireTarget::Refused(ERR_INVALID_TARGET);
|
||||
}
|
||||
// Candidate 1: the current selection (ArmCast's explicit-guid leg — for a player caster the
|
||||
// `Attributes & 0x200` "caster's own target" leg resolves to the same unit).
|
||||
if let Some(guid) = selection_guid {
|
||||
let is_self = self_guid == Some(guid);
|
||||
// Candidate: the current selection — ArmCast's explicit-guid leg (`6e5393`) falling through
|
||||
// to the current-target global `0xb4e2d8` (`6e539f`). Reached only when the main-hand leg
|
||||
// above did not claim the cast.
|
||||
if let Some(guid) = cand.selection {
|
||||
let is_self = cand.caster == Some(guid);
|
||||
if clear_satisfied_bits(word, is_self, rel) == 0 {
|
||||
return CastWireTarget::Unit(guid);
|
||||
}
|
||||
}
|
||||
// Candidate 2: the active player (`0x6e53d7`), behind autoSelfCast.
|
||||
// The fallback: the active player (`0x6e53d7`), behind autoSelfCast.
|
||||
if auto_self_cast {
|
||||
if let Some(guid) = self_guid {
|
||||
if let Some(guid) = cand.caster {
|
||||
let self_rel = TargetRelations {
|
||||
target_store: rel.self_store,
|
||||
..*rel
|
||||
@@ -384,7 +460,7 @@ pub(crate) fn resolve_cast_target(
|
||||
}
|
||||
}
|
||||
// The ref's residual-word targeting-cursor mode, refused locally (module docs).
|
||||
CastWireTarget::Refused(if selection_guid.is_some() {
|
||||
CastWireTarget::Refused(if cand.selection.is_some() {
|
||||
ERR_INVALID_TARGET
|
||||
} else {
|
||||
ERR_NO_TARGET
|
||||
@@ -395,6 +471,16 @@ pub(crate) fn resolve_cast_target(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two unit-side candidates, named — the third (`main_hand_item`) is `None` for every
|
||||
/// test that predates the imbue leg.
|
||||
fn cands(selection: Option<u64>, caster: Option<u64>) -> CastCandidates {
|
||||
CastCandidates {
|
||||
selection,
|
||||
caster,
|
||||
main_hand_item: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn spell(targets: u32, implicit: u32) -> SpellDisplay {
|
||||
SpellDisplay {
|
||||
targets,
|
||||
@@ -444,44 +530,44 @@ mod tests {
|
||||
};
|
||||
let ice_armor = spell(0, 1);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&ice_armor), Some(42), Some(1), true, &rel),
|
||||
resolve_cast_target(Some(&ice_armor), &cands(Some(42), Some(1)), true, &rel),
|
||||
CastWireTarget::SelfImplicit,
|
||||
"a self spell ignores the selection entirely"
|
||||
);
|
||||
let fireball = spell(0, 6);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&fireball), None, Some(1), true, &rel),
|
||||
resolve_cast_target(Some(&fireball), &cands(None, Some(1)), true, &rel),
|
||||
CastWireTarget::Refused(ERR_NO_TARGET)
|
||||
);
|
||||
// Reaction defaults to neutral (3) with no stores: attackable (≤3), not assistable.
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&fireball), Some(42), Some(1), true, &rel),
|
||||
resolve_cast_target(Some(&fireball), &cands(Some(42), Some(1)), true, &rel),
|
||||
CastWireTarget::Unit(42)
|
||||
);
|
||||
let intellect = spell(0, 21);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), Some(42), Some(1), true, &rel),
|
||||
resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), true, &rel),
|
||||
CastWireTarget::Unit(1),
|
||||
"a friendly-required cast on a non-friend falls back to self"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), Some(42), Some(1), false, &rel),
|
||||
resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), false, &rel),
|
||||
CastWireTarget::Refused(ERR_INVALID_TARGET),
|
||||
"autoSelfCast off: the fallback is gated"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), None, Some(1), true, &rel),
|
||||
resolve_cast_target(Some(&intellect), &cands(None, Some(1)), true, &rel),
|
||||
CastWireTarget::Unit(1),
|
||||
"no selection at all still self-falls-back"
|
||||
);
|
||||
// A hostile-required cast never self-binds: player fails CanAttack.
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&fireball), None, Some(1), false, &rel),
|
||||
resolve_cast_target(Some(&fireball), &cands(None, Some(1)), false, &rel),
|
||||
CastWireTarget::Refused(ERR_NO_TARGET)
|
||||
);
|
||||
// Unknown spell: the legacy passthrough.
|
||||
assert_eq!(
|
||||
resolve_cast_target(None, Some(42), Some(1), true, &rel),
|
||||
resolve_cast_target(None, &cands(Some(42), Some(1)), true, &rel),
|
||||
CastWireTarget::Unit(42)
|
||||
);
|
||||
}
|
||||
@@ -495,20 +581,24 @@ mod tests {
|
||||
fn ground_masks_enter_targeting_mode() {
|
||||
let flamestrike = spell(0x40, 16);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&flamestrike), Some(42), Some(1), true, &rel_none()),
|
||||
resolve_cast_target(
|
||||
Some(&flamestrike),
|
||||
&cands(Some(42), Some(1)),
|
||||
true,
|
||||
&rel_none()
|
||||
),
|
||||
CastWireTarget::Targeting(TF_DEST_LOCATION)
|
||||
);
|
||||
let blizzard = spell(0x40, 28);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&blizzard), None, Some(1), true, &rel_none()),
|
||||
resolve_cast_target(Some(&blizzard), &cands(None, Some(1)), true, &rel_none()),
|
||||
CastWireTarget::Targeting(TF_DEST_LOCATION)
|
||||
);
|
||||
let self_commit_with_ground_arm = spell(0, 16);
|
||||
assert_eq!(
|
||||
resolve_cast_target(
|
||||
Some(&self_commit_with_ground_arm),
|
||||
None,
|
||||
Some(1),
|
||||
&cands(None, Some(1)),
|
||||
true,
|
||||
&rel_none()
|
||||
),
|
||||
@@ -526,7 +616,7 @@ mod tests {
|
||||
for targets in [0x20u32, 0x2000, 0x60] {
|
||||
let s = spell(targets, 0);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&s), Some(42), Some(1), true, &rel_none()),
|
||||
resolve_cast_target(Some(&s), &cands(Some(42), Some(1)), true, &rel_none()),
|
||||
CastWireTarget::Refused(ERR_INVALID_TARGET),
|
||||
"Targets {targets:#x} must stay refused"
|
||||
);
|
||||
@@ -562,12 +652,12 @@ mod tests {
|
||||
let s = spell(targets, implicit);
|
||||
let word = cast_target_mask(&s);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&s), Some(42), Some(1), true, &rel_none()),
|
||||
resolve_cast_target(Some(&s), &cands(Some(42), Some(1)), true, &rel_none()),
|
||||
CastWireTarget::Targeting(word),
|
||||
"Targets {targets:#x} + implicit arm {implicit} must raise the cursor with its word"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&s), None, Some(1), false, &rel_none()),
|
||||
resolve_cast_target(Some(&s), &cands(None, Some(1)), false, &rel_none()),
|
||||
CastWireTarget::Targeting(word),
|
||||
"no selection and no autoSelfCast change nothing — no unit clears these bits"
|
||||
);
|
||||
@@ -578,6 +668,75 @@ mod tests {
|
||||
assert_eq!(opening & (TF_GAMEOBJECT | TF_LOCKED), opening);
|
||||
}
|
||||
|
||||
/// The weapon-imbue leg (decision 1552, ledger B308): `Attributes & 0x200` makes the cast
|
||||
/// bind the equipped main hand and send on the press — no cursor, no click on the bag.
|
||||
///
|
||||
/// Rockbiter Weapon 8017's real shape: `Targets 0x10`, `Attributes 0x50200`, implicit arm 0.
|
||||
/// The three things that must stay true around it are the interesting part: the selection is
|
||||
/// irrelevant (a targeted enemy neither breaks the imbue nor gets it cast at them), an empty
|
||||
/// main hand falls back to the ordinary item cursor rather than sending a guess, and the
|
||||
/// *same word without the attribute* — every poison, oil, stone and enchant — still raises the
|
||||
/// cursor exactly as before.
|
||||
#[test]
|
||||
fn the_imbue_attribute_binds_the_main_hand_without_a_cursor() {
|
||||
let rockbiter = SpellDisplay {
|
||||
targets: 0x10,
|
||||
attributes: 0x0005_0200,
|
||||
effects: [benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, 0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(rockbiter.targets_main_hand_item());
|
||||
const WEAPON: u64 = 0xdead_beef;
|
||||
let armed = CastCandidates {
|
||||
selection: Some(42),
|
||||
caster: Some(1),
|
||||
main_hand_item: Some(WEAPON),
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&rockbiter), &armed, true, &rel_none()),
|
||||
CastWireTarget::Item(WEAPON),
|
||||
"the imbue binds the equipped weapon on the press"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(
|
||||
Some(&rockbiter),
|
||||
&CastCandidates {
|
||||
selection: None,
|
||||
..armed
|
||||
},
|
||||
false,
|
||||
&rel_none()
|
||||
),
|
||||
CastWireTarget::Item(WEAPON),
|
||||
"neither the selection nor autoSelfCast has anything to do with it"
|
||||
);
|
||||
// Unarmed: the ref's esi:edi = 0 resolves no object, the bind fails, the word stands.
|
||||
assert_eq!(
|
||||
resolve_cast_target(
|
||||
Some(&rockbiter),
|
||||
&cands(Some(42), Some(1)),
|
||||
true,
|
||||
&rel_none()
|
||||
),
|
||||
CastWireTarget::Targeting(TF_ITEM),
|
||||
"no main hand — the item cursor, not a guessed target"
|
||||
);
|
||||
// The attribute is the whole difference: a sharpening stone / poison / oil / enchant is
|
||||
// the same `Targets 0x10` word and must still ask for its item.
|
||||
let stone = SpellDisplay {
|
||||
targets: 0x10,
|
||||
attributes: 0x0004_0000,
|
||||
effects: [benilla_formats::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, 0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!stone.targets_main_hand_item());
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&stone), &armed, true, &rel_none()),
|
||||
CastWireTarget::Targeting(TF_ITEM),
|
||||
"without the attribute the cursor comes up even with a weapon equipped"
|
||||
);
|
||||
}
|
||||
|
||||
fn rel_none() -> TargetRelations<'static> {
|
||||
static EMPTY: Reputations = Reputations(Vec::new());
|
||||
TargetRelations {
|
||||
|
||||
@@ -127,9 +127,11 @@ pub(super) fn emote_target(selection: &Selection, me: Option<Entity>) -> u64 {
|
||||
/// - `world` feeds **`/liquid`**, the swim diagnostic (decision 0634 follow-up): the interior
|
||||
/// claim is what decides which liquid surfaces the swim query may see, and it arrives with the
|
||||
/// query rather than beside it.
|
||||
/// - `stores`/`self_store`/`factions`/`reputations` feed **`/reaction`**, the attackability
|
||||
/// diagnostic (decision 0637): the exact inputs [`crate::target::ring_reaction`] judges on, so
|
||||
/// "why is this unit not attackable" is one command instead of a guess.
|
||||
/// - `stores`/`self_store`/`factions`/`reputations`/`kinds` feed **`/reaction`**, the
|
||||
/// attackability diagnostic (decision 0637): the exact inputs [`crate::target::ring_reaction`]
|
||||
/// judges on, so "why is this unit not attackable" is one command instead of a guess — and
|
||||
/// with them the V-plate CATEGORY, which turns on a different predicate entirely
|
||||
/// ([`crate::target::ring::plate_is_friendly`]) and so cannot be read off the rank.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct ChatProbes<'w, 's> {
|
||||
camera: Query<
|
||||
@@ -148,6 +150,9 @@ pub(super) struct ChatProbes<'w, 's> {
|
||||
/// `/reaction <name>`'s resolve — so a scripted probe can ask about a player it has not
|
||||
/// clicked (the two-client duel run has no way to select the other side).
|
||||
guids: Res<'w, crate::net::GuidIndex>,
|
||||
/// The subject's [`benilla_protocol::EntityKind`] — the plate gate's own player test, so the
|
||||
/// diagnostic reports the branch the gate actually took rather than a second guess at it.
|
||||
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
|
||||
}
|
||||
|
||||
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
|
||||
@@ -204,6 +209,7 @@ pub(super) fn drain_chat_input(
|
||||
factions,
|
||||
reputations,
|
||||
guids,
|
||||
kinds,
|
||||
} = &probes;
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -385,10 +391,13 @@ pub(super) fn drain_chat_input(
|
||||
Some(n) => crate::ui_duel::streamed_player_named(n, guids, &names),
|
||||
None => selection.guid,
|
||||
};
|
||||
let target_store = subject_guid
|
||||
.and_then(|g| guids.0.get(&g).copied())
|
||||
.and_then(|e| stores.get(e).ok());
|
||||
let subject_entity = subject_guid.and_then(|g| guids.0.get(&g).copied());
|
||||
let target_store = subject_entity.and_then(|e| stores.get(e).ok());
|
||||
let own_store = self_store.iter().next();
|
||||
// The plate gate's own player test, read the same way it reads it.
|
||||
let is_player = subject_entity
|
||||
.and_then(|e| kinds.get(e).ok())
|
||||
.is_some_and(|n| n.kind == benilla_protocol::EntityKind::Player);
|
||||
let mut lines = Vec::new();
|
||||
let describe = |label: &str, s: Option<&crate::net::ObjectStore>| match s {
|
||||
Some(s) => format!(
|
||||
@@ -436,6 +445,47 @@ pub(super) fn drain_chat_input(
|
||||
own_store
|
||||
)
|
||||
));
|
||||
// The V-plate CATEGORY, which is a **different predicate** from the rank above
|
||||
// (`CanAttack` player→unit, plus `CanCooperate` for a player subject — decision
|
||||
// 1530) and so cannot be read off it. Both legs are printed, with the faction-group
|
||||
// masks they compare: a player in the wrong bucket is a mask disagreement roughly
|
||||
// every time, and the mask is invisible everywhere else in the client. A GM-mode
|
||||
// character is mask 0 on a vmangos realm (`.gm on` → faction template 35), which is
|
||||
// exactly how a friendly player ends up with an enemy plate.
|
||||
let mask = |s| {
|
||||
crate::target::ring::faction_group_mask(factions.as_deref(), s)
|
||||
.map_or("none".to_string(), |m| m.to_string())
|
||||
};
|
||||
let friendly = crate::target::ring::plate_is_friendly(
|
||||
factions.as_deref(),
|
||||
reputations,
|
||||
target_store,
|
||||
own_store,
|
||||
is_player,
|
||||
);
|
||||
lines.push(format!(
|
||||
"reaction: plate is_player {is_player} · faction-group mask self {} \
|
||||
target {} · can_cooperate {} · can_attack(player→unit) {}",
|
||||
mask(own_store),
|
||||
mask(target_store),
|
||||
crate::target::ring::can_cooperate_with_player(
|
||||
factions.as_deref(),
|
||||
target_store,
|
||||
own_store
|
||||
),
|
||||
crate::target::ring::can_attack_from_player(
|
||||
factions.as_deref(),
|
||||
reputations,
|
||||
target_store,
|
||||
own_store,
|
||||
is_player,
|
||||
),
|
||||
));
|
||||
lines.push(format!(
|
||||
"reaction: PLATE CATEGORY {} — this unit plates under {}",
|
||||
if friendly { "FRIENDLY" } else { "ENEMY" },
|
||||
if friendly { "SHIFT-V" } else { "V" },
|
||||
));
|
||||
for line in lines {
|
||||
// Also to the log: a scripted two-client run reads stdout, not the feed.
|
||||
info!("{line}");
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
//! - **Gate**: never the own unit; never `NOT_SELECTABLE` (UNIT_FIELD_FLAGS bit 25); the
|
||||
//! enemy/friendly split is **`CanAttack(localPlayer → unit)`**, NOT a reaction threshold
|
||||
//! ([`crate::target::ring::plate_is_friendly`], wow-re `nameplate-category-gate.md` §2/§3,
|
||||
//! §5-VERIFIED 2026-08-22 — this file shipped `rank >= 4` for a year and 1530 corrects it);
|
||||
//! **max 20 yd**, hardcoded; **no
|
||||
//! §5-VERIFIED 2026-08-22 — this file shipped `rank >= 4` for a year and 1530 corrects it) —
|
||||
//! and a **player** subject must additionally pass `CanCooperate`, which is nothing but the two
|
||||
//! `FactionTemplate` faction-group masks being equal, so a player whose row carries neither
|
||||
//! side's mask (a vmangos `.gm on` character is template 35, mask 0) is enemy-category no matter
|
||||
//! how friendly they read; **max 20 yd**, hardcoded; **no
|
||||
//! occlusion** (a 2-D overlay — plates draw through walls); snap, no smoothing. Anti-overlap
|
||||
//! there *is*, since 0367 found the shared solver ([`crate::smart_rect`]) — this file's older
|
||||
//! "no anti-overlap" was the census that missed it. And the sphere is bounded by the frustum:
|
||||
|
||||
@@ -841,6 +841,76 @@ fn the_tooltip_gates_read_effect_and_mask() {
|
||||
// vocabulary it reads lives — see `itemsubclass::tests` for its coverage against the real DBCs.
|
||||
}
|
||||
|
||||
/// The **main-hand auto-pick** family (`Attributes & 0x200`, decision 1552), against the real
|
||||
/// 5875 file. `ArmCast 0x6e5250` reads this bit to skip the item-targeting cursor and bind the
|
||||
/// equipped main hand instead, so the client's behaviour rests on the family being exactly what
|
||||
/// the app assumes: a set of temporary weapon imbues, each already an item-target spell.
|
||||
///
|
||||
/// **vmangos is not the authority for this census and gets it wrong.** Its `spell_template` keeps
|
||||
/// a row per *build*: mage Feedback (13896, 19271-19275) and druid Omen of Clarity carried this
|
||||
/// exact shape — effect 54, `Targets 0x10`, `Attributes 0x50200` — at builds 4375/4449, and both
|
||||
/// had been rewritten into plain auras with the bit CLEAR by build 5302. A `GROUP BY entry` over
|
||||
/// that table hands back the early row and inflates the family to 28. The shipped file is the
|
||||
/// authority, and it says four names.
|
||||
///
|
||||
/// The app's resolver takes the shortcut of only handling a word the item arm fully discharges
|
||||
/// (`Targets` exactly `0x10`). That is byte-equivalent only while no row mixes the attribute with
|
||||
/// another target bit — which is what the `0x10` assertion below pins. If a row ever does, this
|
||||
/// fails here rather than silently sending an unbound cast.
|
||||
#[test]
|
||||
fn real_main_hand_autopick_family() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
|
||||
|
||||
let mut names = std::collections::BTreeSet::<String>::new();
|
||||
let mut rows = 0usize;
|
||||
for (id, d) in cat.iter().filter(|(_, d)| d.targets_main_hand_item()) {
|
||||
rows += 1;
|
||||
names.insert(d.name.clone());
|
||||
assert_eq!(
|
||||
d.targets, 0x10,
|
||||
"spell {id} ({}) carries the main-hand attribute with word {:#x} — the resolver's \
|
||||
item arm cannot discharge it",
|
||||
d.name, d.targets
|
||||
);
|
||||
assert_eq!(
|
||||
d.effects[0],
|
||||
crate::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY,
|
||||
"spell {id} ({}) is not a temporary weapon enchant",
|
||||
d.name
|
||||
);
|
||||
// The auto-pick bypasses `0x495d60` entirely, so an equipped-item gate on one of these
|
||||
// rows would be a gate the client never runs. None carries one.
|
||||
assert_eq!(
|
||||
(
|
||||
d.equipped_item_subclass_mask,
|
||||
d.equipped_item_inventory_type_mask
|
||||
),
|
||||
(0, 0),
|
||||
"spell {id} ({}) carries an equipped-item gate the auto-pick path never runs",
|
||||
d.name
|
||||
);
|
||||
}
|
||||
assert_eq!(rows, 22, "the whole main-hand auto-pick family");
|
||||
assert_eq!(
|
||||
names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
[
|
||||
"Flametongue Weapon",
|
||||
"Frostbrand Weapon",
|
||||
"Rockbiter Weapon",
|
||||
"Windfury Weapon",
|
||||
],
|
||||
"in 5875 the family is the four shaman weapon imbues and nothing else"
|
||||
);
|
||||
// Windfury TOTEM is not in it — it is a totem summon with no target word at all. Worth
|
||||
// pinning because the report that prompted 1552 named it (ledger B308), and the fix would
|
||||
// look wrong if it had ever needed one.
|
||||
let wf_totem = cat.get(8512).expect("Windfury Totem");
|
||||
assert!(!wf_totem.targets_main_hand_item());
|
||||
assert_eq!(wf_totem.targets, 0, "a totem summon binds nothing");
|
||||
}
|
||||
|
||||
/// The item-target family and its gate columns (decision 0923), against the real 5875 file. The
|
||||
/// reference's `TargetingWantsItem 0x6e6330` is `flag_word & 0x4010`, and on shipped data those
|
||||
/// two bits are **never** mixed with a unit bit — the whole family is `Targets` exactly `0x10`
|
||||
|
||||
@@ -35,8 +35,9 @@ pub struct SpellDisplay {
|
||||
/// impact (decision 0099's `Speed==0` gate — no missile phase).
|
||||
pub speed: f32,
|
||||
/// `Attributes` (column 6, module docs) — consumed by [`Self::ranged_attack`] (`0x2`),
|
||||
/// [`Self::in_spellbook`] (`0x20`/`0x80`), [`Self::cooldown_on_event`] (bit 25), and the
|
||||
/// aura-bar display filter [`Self::hidden_from_aura_bar`] (`0x80`).
|
||||
/// [`Self::targets_main_hand_item`] (`0x200`), [`Self::in_spellbook`] (`0x20`/`0x80`),
|
||||
/// [`Self::cooldown_on_event`] (bit 25), and the aura-bar display filter
|
||||
/// [`Self::hidden_from_aura_bar`] (`0x80`).
|
||||
pub attributes: u32,
|
||||
/// `AttributesEx` (column 7, `SpellRec+0x1c`) — only bit `0x10000000` is consumed
|
||||
/// ([`Self::hidden_from_aura_bar`]'s `SPELL_ATTR_EX_NO_AURA_ICON` half).
|
||||
@@ -399,6 +400,28 @@ impl SpellDisplay {
|
||||
self.attributes_ex2 & ATTR_EX2_AUTO_REPEAT != 0 || self.attributes & ATTR_RANGED != 0
|
||||
}
|
||||
|
||||
/// **This cast aims itself at the equipped main hand** — `Attributes & 0x200`
|
||||
/// ([`ATTR_TARGET_MAIN_HAND_ITEM`]), the client's own auto-pick for a weapon imbue.
|
||||
///
|
||||
/// `ArmCast 0x6e5250` resolves the cast's candidate guid from ONE of three places, and this
|
||||
/// bit picks the first: for a **player** caster (`6e5361`: `[[caster+8]+8] >> 4 & 1`, the
|
||||
/// typemask-`0x10` test) carrying the bit (`6e5371: test ah,0x2`), the candidate is the
|
||||
/// player's inventory slot table entry 15 — `[player+0x1d3c][15]`, `EQUIPMENT_SLOT_MAINHAND`
|
||||
/// (`6e5385`–`6e538e`, the `+0x78/+0x7c` guid pair; the table is wow-re's pinned
|
||||
/// `[player+0x1d38]` count / `[player+0x1d3c]` array, bounds-checked at `6e5376`). Only if
|
||||
/// the bit is clear does the walk fall to the explicit guid, then to the current selection
|
||||
/// (`6e5393`/`6e539f`).
|
||||
///
|
||||
/// So a spell carrying it never raises the item-targeting cursor over an equipped weapon:
|
||||
/// the imbue binds and sends on the press. Exactly 22 rows of the shipped 5875 `Spell.dbc`
|
||||
/// carry the bit, four distinct names — **Rockbiter / Flametongue / Frostbrand / Windfury
|
||||
/// Weapon**, every rank of the shaman's weapon imbues and nothing else — and every one is
|
||||
/// `Targets == 0x10` with an `ENCHANT_ITEM_TEMPORARY` effect (censused against the real file
|
||||
/// in `catalog_tests.rs`, which also records why vmangos answers 28). Decision 1552.
|
||||
pub fn targets_main_hand_item(&self) -> bool {
|
||||
self.attributes & ATTR_TARGET_MAIN_HAND_ITEM != 0
|
||||
}
|
||||
|
||||
/// The auto-repeat attribute **alone** (`AttributesEx2 & 0x20`) — the narrower gate on the
|
||||
/// client's local auto-repeat state bit (`[+0xd58] & 0x200`, written only at the local
|
||||
/// cast-send `0x6e593b`), which drives the shooter's standing Load/Hold idle. `Throw` is
|
||||
|
||||
@@ -375,6 +375,12 @@ const ATTR_EX3_NO_CASTING_BAR_TEXT: u32 = 0x4;
|
||||
/// `Attributes` bit `0x2` — the "uses the ranged slot" attribute (mangos `SPELL_ATTR_RANGED`).
|
||||
/// One half of the client's ranged-stance gate (module docs).
|
||||
const ATTR_RANGED: u32 = 0x2;
|
||||
/// `Attributes` bit 9 (`0x200`) — **this cast targets the equipped main hand, without asking**
|
||||
/// (`SPELL_ATTR0_TARGET_MAINHAND_ITEM`). vmangos leaves the bit unnamed (`SPELL_ATTR_UNK9`)
|
||||
/// because it is a purely *client-side* targeting instruction: the server only ever sees the
|
||||
/// resolved item guid. The law is `ArmCast 0x6e5250`'s candidate leg (`6e536e`–`6e5391`), read by
|
||||
/// [`SpellDisplay::targets_main_hand_item`]; decision 1552.
|
||||
const ATTR_TARGET_MAIN_HAND_ITEM: u32 = 0x200;
|
||||
/// `AttributesEx2` bit `0x20` — the auto-repeat attribute (mangos `SPELL_ATTR_EX2_AUTO_REPEAT`);
|
||||
/// the other half of the gate. Auto Shot and wand Shoot carry it.
|
||||
const ATTR_EX2_AUTO_REPEAT: u32 = 0x20;
|
||||
|
||||
@@ -86,6 +86,14 @@ pub struct SoundDebug {
|
||||
pub kit_query: String,
|
||||
/// One-shot: play `kit_query` through the kit player.
|
||||
pub play_kit: bool,
|
||||
/// How many copies of `kit_query` the probe fires **in one frame** (decision 1551).
|
||||
///
|
||||
/// The reported defect is an overlap defect — "a lot of mobs attacking same time, or a priest
|
||||
/// buffing a group with mass fort" — and it needs no game state to reproduce, only N
|
||||
/// sample-aligned copies of one kit. Five copies of kit 3116 (`HolyProtection`) *is* mass
|
||||
/// Fortitude on a full party, arithmetically: the health line reports the peak the mix asked
|
||||
/// for, and `SoundOutputLimiter` A/Bs what it sounds like.
|
||||
pub play_copies: u32,
|
||||
}
|
||||
|
||||
impl Default for SoundDebug {
|
||||
@@ -94,6 +102,7 @@ impl Default for SoundDebug {
|
||||
// A UI kit with several variations — exercises the depleting weighted pick.
|
||||
kit_query: "igMiniMapZoomIn".into(),
|
||||
play_kit: false,
|
||||
play_copies: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user