raid tab art is back, set bonuses fit, and 12 voices bound the mix
- the mix is bounded the way the reference's is: FMOD gave 1.12 exactly twelve software channels and nothing else limits a priest buffing five people — ported as a 12-voice ceiling (a probe capture read 42 live voices at +13 dBFS, the limiter pumping 13 dB to hold it), plus same-kit copies started in one frame capped at two, since five sample-aligned Fortitudes are one sound +14 dB; `$WOW_SOUND_PROBE` records the mix before and after the limiter with a timeline, F9 stamps the moment - the raid tab draws its window art again — its four corner textures were written with single backslashes, which Lua drops silently, and a sweep now reads every archive path a shipped Lua chunk names; a drag whose release never arrives (pointer left the window, a second press) ends instead of gluing the frame to the cursor for the rest of the session, and `WOW_PROBE_DRAG` drives any UI drag headlessly in a live client - the item tooltip fits its set bonuses again: an emptied line kept its last measured height forever (a pooled cell with no way to say "empty"), and the two blank rows above the set block are the reference's own literal (a space and a newline) — a whole row each, not the empty string - an equipped BoE or BoP piece reads Soulbound: the bind line reads the item's own flags through the client's bound predicate instead of the template's bonding, one predicate shared with the enchant cursor's bind confirm Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -343,10 +343,10 @@
|
||||
-- CHARACTER window's bottom art rather than art of its own — there is no
|
||||
-- RaidFrame-BotLeft in the patch chain, because the pane is a grid on a plain
|
||||
-- parchment rather than a list in a frame.
|
||||
FriendsFrameTopLeft:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-TopLeft")
|
||||
FriendsFrameTopRight:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-TopRight")
|
||||
FriendsFrameBottomLeft:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-BottomLeft")
|
||||
FriendsFrameBottomRight:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-BottomRight")
|
||||
FriendsFrameTopLeft:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-TopLeft")
|
||||
FriendsFrameTopRight:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-TopRight")
|
||||
FriendsFrameBottomLeft:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-BottomLeft")
|
||||
FriendsFrameBottomRight:SetTexture("Interface\\PaperDollInfoFrame\\UI-Character-General-BottomRight")
|
||||
FriendsFrameTitleText:SetText(RAID)
|
||||
FriendsFrame_ShowSubFrame("RaidFrame")
|
||||
end
|
||||
|
||||
@@ -1315,6 +1315,7 @@ fn seed_bag_window(
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
already_bound: false,
|
||||
enchants: Vec::new(),
|
||||
}
|
||||
};
|
||||
@@ -1404,6 +1405,7 @@ fn seed_equipped_bags(
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
already_bound: false,
|
||||
enchants: Vec::new(),
|
||||
};
|
||||
|
||||
|
||||
@@ -122,8 +122,8 @@ pub(crate) use probe_taxi::ProbeTaxiPlugin;
|
||||
pub(crate) use probes::{
|
||||
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
|
||||
LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock,
|
||||
ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
|
||||
RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin,
|
||||
ProbeDragPlugin, ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
|
||||
ProbeResizePlugin, RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin,
|
||||
};
|
||||
use scenarios::GlueScreen;
|
||||
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
|
||||
|
||||
@@ -369,3 +369,205 @@ fn fire_probe_lua(
|
||||
error!("probe-lua: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The PROBE DRAG driver (`WOW_PROBE_DRAG="A>B[;C>D…]"`, delay via `WOW_PROBE_DRAG_AT` seconds
|
||||
/// (default 14), one gesture step per `WOW_PROBE_DRAG_STEP` seconds (default 0.1)): drag one named
|
||||
/// frame onto another **through the real pointer path**, headlessly, in a live session.
|
||||
///
|
||||
/// **Why this exists as an instrument rather than a unit test.** The UI's own harness can already
|
||||
/// press, move and release ([`UiScript::mouse_button`]/`mouse_move`), and a drag test written
|
||||
/// there passes while the live client's drag is broken — because the harness supplies the whole
|
||||
/// world: no per-frame app feed between the press and the release, no real frames elapsing, no
|
||||
/// resolve pass, no `feed_party` re-push, no OS cursor. That gap cost a session (B310: the raid
|
||||
/// grid's second drag). This closes it by driving the SAME gesture in a real client: the press,
|
||||
/// the threshold-crossing move, the path, and the release each land on their own frame, with
|
||||
/// every app system running in between exactly as it does for a hand on the mouse.
|
||||
///
|
||||
/// It names FRAMES, not coordinates, and reads their centres out of the live layout, so a probe
|
||||
/// line survives a window moving. `WOW_PROBE_DRAG_LUA="<chunk>"` runs a chunk after each gesture —
|
||||
/// the report channel (`ProbeLog` from [`ProbeLuaPlugin`] is installed by that probe; this one
|
||||
/// prints the chunk's own returned string).
|
||||
pub(crate) struct ProbeDragPlugin;
|
||||
|
||||
impl Plugin for ProbeDragPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let pairs = std::env::var("WOW_PROBE_DRAG")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.filter_map(|p| p.split_once('>'))
|
||||
.map(|(a, b)| (a.trim().to_string(), b.trim().to_string()))
|
||||
.collect();
|
||||
let at = std::env::var("WOW_PROBE_DRAG_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(14.0);
|
||||
let step = std::env::var("WOW_PROBE_DRAG_STEP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.1);
|
||||
app.insert_resource(ProbeDrag {
|
||||
pairs,
|
||||
at,
|
||||
step,
|
||||
report: std::env::var("WOW_PROBE_DRAG_LUA").unwrap_or_default(),
|
||||
pair: 0,
|
||||
phase: 0,
|
||||
next: 0.0,
|
||||
from: (0.0, 0.0),
|
||||
to: (0.0, 0.0),
|
||||
})
|
||||
.add_systems(Update, fire_probe_drag);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProbeDragPlugin`]'s state machine: which pair, which step of the gesture, and when the next
|
||||
/// step is due.
|
||||
#[derive(Resource)]
|
||||
struct ProbeDrag {
|
||||
pairs: Vec<(String, String)>,
|
||||
at: f32,
|
||||
step: f32,
|
||||
report: String,
|
||||
pair: usize,
|
||||
/// The step within the current gesture — see [`fire_probe_drag`]'s table.
|
||||
phase: usize,
|
||||
next: f32,
|
||||
from: (f32, f32),
|
||||
to: (f32, f32),
|
||||
}
|
||||
|
||||
/// One frame's centre in the VM's own units, read out of the live layout (`GetLeft`/`GetRight`/
|
||||
/// `GetTop`/`GetBottom` — the same space [`crate::ui_script::input`] feeds the cursor in).
|
||||
/// `None` when the frame does not exist or has no resolved rect.
|
||||
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
|
||||
let read = |edge: &str| {
|
||||
script
|
||||
.eval::<f32>(&format!(
|
||||
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
|
||||
))
|
||||
.ok()
|
||||
};
|
||||
Some((
|
||||
(read("Left")? + read("Right")?) / 2.0,
|
||||
(read("Top")? + read("Bottom")?) / 2.0,
|
||||
))
|
||||
}
|
||||
|
||||
/// Advance the scripted drag by at most one step per `probe.step` seconds.
|
||||
///
|
||||
/// The gesture is deliberately spread across real frames rather than run in one call: a press and
|
||||
/// a release inside a single frame is a *click*, and the whole point here is to exercise what the
|
||||
/// app does BETWEEN them.
|
||||
fn fire_probe_drag(
|
||||
mut probe: ResMut<ProbeDrag>,
|
||||
mut synthetic: ResMut<crate::ui_script::SyntheticPointer>,
|
||||
time: ProbeClock,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
self_player: Query<(), With<crate::net::SelfPlayer>>,
|
||||
) {
|
||||
if probe.pairs.is_empty() || probe.pair >= probe.pairs.len() {
|
||||
if synthetic.0 {
|
||||
synthetic.0 = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
if now < probe.at || self_player.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut script) = script else { return };
|
||||
if now < probe.next {
|
||||
return;
|
||||
}
|
||||
probe.next = now + probe.step;
|
||||
// The probe owns the pointer from the first step of a gesture to the last, never longer.
|
||||
synthetic.0 = true;
|
||||
|
||||
let (from_name, to_name) = probe.pairs[probe.pair].clone();
|
||||
match probe.phase {
|
||||
// Read both centres and park the cursor on the source — the hover the player makes
|
||||
// before they press.
|
||||
0 => {
|
||||
let (Some(from), Some(to)) = (
|
||||
frame_centre(&script, &from_name),
|
||||
frame_centre(&script, &to_name),
|
||||
) else {
|
||||
warn!("probe-drag: {from_name} > {to_name} — no resolved rect, skipping");
|
||||
probe.pair += 1;
|
||||
return;
|
||||
};
|
||||
probe.from = from;
|
||||
probe.to = to;
|
||||
info!(
|
||||
"probe-drag: {from_name} ({:.0},{:.0}) > {to_name} ({:.0},{:.0})",
|
||||
from.0, from.1, to.0, to.1
|
||||
);
|
||||
script.mouse_move(from.0, from.1);
|
||||
}
|
||||
1 => {
|
||||
let (x, y) = probe.from;
|
||||
script.mouse_button(x, y, "LeftButton", true);
|
||||
}
|
||||
// Past the 4-px drag threshold — this is the move that fires `OnDragStart`.
|
||||
2 => {
|
||||
let (x, y) = probe.from;
|
||||
script.mouse_move(x + 8.0, y + 8.0);
|
||||
}
|
||||
// Two waypoints along the path, so anything that only breaks while the drag is IN FLIGHT
|
||||
// (a repaint that re-seats the row under the cursor, say) gets frames to do it in.
|
||||
3 | 4 => {
|
||||
let t = if probe.phase == 3 { 0.4 } else { 0.8 };
|
||||
let (fx, fy) = probe.from;
|
||||
let (tx, ty) = probe.to;
|
||||
script.mouse_move(fx + (tx - fx) * t, fy + (ty - fy) * t);
|
||||
}
|
||||
5 => {
|
||||
let (x, y) = probe.to;
|
||||
script.mouse_move(x, y);
|
||||
}
|
||||
6 => {
|
||||
let (x, y) = probe.to;
|
||||
script.mouse_button(x, y, "LeftButton", false);
|
||||
}
|
||||
// The self-check: hover the source where it now DRAWS and ask who the hit test says is
|
||||
// there. A frame whose hit rect and drawn rect have parted company is invisible to any
|
||||
// harness that presses the centre it just read — and it is exactly what "I can't grab
|
||||
// anything any more" looks like from a hand on the mouse.
|
||||
7 => {
|
||||
if let Some((x, y)) = frame_centre(&script, &from_name) {
|
||||
script.mouse_move(x, y);
|
||||
let focus = script
|
||||
.eval::<String>(
|
||||
"return tostring(GetMouseFocus() and GetMouseFocus():GetName())",
|
||||
)
|
||||
.unwrap_or_else(|_| "<eval failed>".into());
|
||||
if focus != from_name && !focus.starts_with(&from_name) {
|
||||
warn!(
|
||||
"probe-drag: {from_name} draws at ({x:.0},{y:.0}) but the hit test there \
|
||||
says {focus}"
|
||||
);
|
||||
} else {
|
||||
info!("probe-drag: {from_name} still answers its own rect");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !probe.report.is_empty() {
|
||||
match script.eval::<String>(&probe.report) {
|
||||
Ok(text) => info!("probe-drag: after {from_name} > {to_name}: {text}"),
|
||||
Err(e) => warn!("probe-drag: report chunk raised: {e}"),
|
||||
}
|
||||
}
|
||||
for err in script.errors().drain(..) {
|
||||
warn!("probe-drag: UI error during {from_name} > {to_name}: {err}");
|
||||
}
|
||||
probe.pair += 1;
|
||||
probe.phase = 0;
|
||||
// Hand the pointer back between gestures: the app's own feed runs for a frame, which
|
||||
// is what a player's hand does too, and is where a stale hover would show up.
|
||||
synthetic.0 = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
probe.phase += 1;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use bevy::prelude::*;
|
||||
mod fx_draw_census;
|
||||
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
|
||||
mod act;
|
||||
pub(crate) use act::{ProbeChatPlugin, ProbeKeyPlugin, ProbeLuaPlugin};
|
||||
pub(crate) use act::{ProbeChatPlugin, ProbeDragPlugin, ProbeKeyPlugin, ProbeLuaPlugin};
|
||||
|
||||
/// The probe **run** itself — its bounded lifetime, and the window it lives in (level,
|
||||
/// parking, size). Nothing here measures anything; it is the shell every other probe rides.
|
||||
|
||||
@@ -133,6 +133,12 @@ impl Plugin for DevProbesPlugin {
|
||||
if std::env::var("WOW_PROBE_LUA").is_ok() {
|
||||
app.add_plugins(crate::capture::ProbeLuaPlugin);
|
||||
}
|
||||
// The probe-drag driver: `WOW_PROBE_DRAG="RaidGroupButton7>RaidGroup6Slot1"` drags one
|
||||
// named frame onto another through the real pointer path, one gesture step per frame —
|
||||
// the "do what the hand on the mouse does" instrument (see `capture::ProbeDragPlugin`).
|
||||
if std::env::var("WOW_PROBE_DRAG").is_ok() {
|
||||
app.add_plugins(crate::capture::ProbeDragPlugin);
|
||||
}
|
||||
// The probe-key taps: `WOW_PROBE_KEY="Space@14"` presses keys once in-world — the "press
|
||||
// space headlessly" instrument for input-gated behavior (see `capture::ProbeKeyPlugin`).
|
||||
if std::env::var("WOW_PROBE_KEY").is_ok() {
|
||||
|
||||
@@ -90,6 +90,44 @@ pub(crate) fn enchant_lines(
|
||||
lines
|
||||
}
|
||||
|
||||
/// `0x5da2c0` — **"has this item already been through the bind question?"**: the instance's
|
||||
/// `ITEM_FIELD_FLAGS & 1` (already soulbound), **or** any of its seven live
|
||||
/// `ITEM_FIELD_ENCHANTMENT` slots naming a `SpellItemEnchantment` row that binds the item
|
||||
/// ([`benilla_formats::EnchantCatalog::binds_the_item`], the ref's `5da300`–`5da320` walk).
|
||||
///
|
||||
/// One predicate, two consumers — the enchant cursor's bind question
|
||||
/// ([`crate::ui_action`]'s `ClickedItem::already_bound`, the `0x495d60` gate) and the item
|
||||
/// tooltip's §6 **Soulbound** override (B310). They must agree: an item the cursor considers
|
||||
/// already bound is exactly an item whose tooltip says *Soulbound*.
|
||||
///
|
||||
/// Read off the RAW descriptor, never off the rendered [`enchant_lines`] list. That list is a
|
||||
/// *display* view: it drops rows the catalog cannot name, and it drops every
|
||||
/// `Flags & 0x2` row outright (the line the reference refuses to print — decision 0928). The two
|
||||
/// flag sets **overlap**, so this is not a hypothetical: **Firestone 1-4 and Orb of Fire carry
|
||||
/// both bits** — they bind the item AND print no line — so an imbued weapon would read back as
|
||||
/// "not bound" from the lines while the reference calls it bound.
|
||||
pub(crate) fn already_bound(fields: &ObjectFields, cat: Option<&Enchants>) -> bool {
|
||||
fields.item_flags().is_some_and(|f| f & 0x1 != 0)
|
||||
|| (0..7).any(|slot| live_enchant(fields, slot, cat).is_some_and(|id| binds(id, cat)))
|
||||
}
|
||||
|
||||
/// One `ITEM_FIELD_ENCHANTMENT` slot as the bind checks read it (`495eec:
|
||||
/// movl 0x40(%ecx,%eax,4)` with `eax = 3*slot`): the raw id must be **positive** (the ref's `jl`
|
||||
/// skip at `495ef4`/`5da306`) and must name a real `SpellItemEnchantment` row (its
|
||||
/// `testl %eax,%eax` after the table load). Anything else is "no enchant here".
|
||||
///
|
||||
/// NB this is the *bind-question* reading, not the *line* reading — the line law names its row
|
||||
/// off `abs(id)` and keeps the sign only for the colour ([`enchant_lines`], wow-re §E3).
|
||||
pub(crate) fn live_enchant(fields: &ObjectFields, slot: u8, cat: Option<&Enchants>) -> Option<u32> {
|
||||
let id = u32::try_from(fields.item_enchant(slot)?).ok()?;
|
||||
cat.is_some_and(|c| c.0.has_row(id)).then_some(id)
|
||||
}
|
||||
|
||||
/// `SpellItemEnchantment.Flags & 1` — this enchant soulbinds the item it lands on.
|
||||
pub(crate) fn binds(id: u32, cat: Option<&Enchants>) -> bool {
|
||||
cat.is_some_and(|c| c.0.binds_the_item(id))
|
||||
}
|
||||
|
||||
/// [`enchant_lines`] without the breadcrumb — the same gate and the same naming, for the one
|
||||
/// caller that is not a live item: the startup resolve of the whole `ItemRandomProperties` table
|
||||
/// ([`random_property_views`]). Routing that through the loud one would fire "the first enchant
|
||||
@@ -605,6 +643,7 @@ mod tests {
|
||||
ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER,
|
||||
};
|
||||
use crossbeam_channel::TryRecvError;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -846,4 +885,68 @@ mod tests {
|
||||
// No DBC → no lines, the pre-0915 tooltip.
|
||||
assert!(enchant_lines([(0, 2564, 0, None)], None).is_empty());
|
||||
}
|
||||
|
||||
/// `SpellItemEnchantment` field 23 for three synthetic rows: one that binds, one that binds
|
||||
/// AND hides its tooltip line (the Firestone shape), one that does neither.
|
||||
fn catalog() -> Enchants {
|
||||
Enchants(benilla_formats::EnchantCatalog::from_rows(
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
HashMap::from([(11, 0x1), (12, 0x1 | 0x2), (13, 0x0)]),
|
||||
))
|
||||
}
|
||||
|
||||
/// One item object with the given `ITEM_FIELD_FLAGS` and enchant slot 0.
|
||||
fn item(flags: u32, slot0: u32) -> ObjectFields {
|
||||
ObjectFields::from_pairs(&[(21, flags), (22, slot0)])
|
||||
}
|
||||
|
||||
/// **`0x5da2c0` — the bind question's predicate**, and the reason it reads the raw descriptor.
|
||||
///
|
||||
/// Two halves, `||`: the instance's soulbound bit, or any live enchant slot naming a row with
|
||||
/// `Flags & 1`. The last case is the one that decides the shape: the Firestone family carries
|
||||
/// BOTH the binding bit and the tooltip-suppression bit, so the same item is bound *and*
|
||||
/// prints no enchant line — a predicate read off [`enchant_lines`] would call it unbound.
|
||||
#[test]
|
||||
fn the_bind_predicate_reads_the_descriptor_not_the_rendered_lines() {
|
||||
let cat = catalog();
|
||||
let cat = Some(&cat);
|
||||
assert!(
|
||||
already_bound(&item(0x1, 0), cat),
|
||||
"ITEM_FIELD_FLAGS & 1 — already soulbound"
|
||||
);
|
||||
assert!(
|
||||
!already_bound(&item(0x0, 0), cat),
|
||||
"no flag, no enchant — the plain BoE"
|
||||
);
|
||||
assert!(
|
||||
!already_bound(&item(0x8, 0), cat),
|
||||
"a WRAPPED gift is not a bound item — the bit is 0x1, not any bit"
|
||||
);
|
||||
assert!(
|
||||
already_bound(&item(0x0, 11), cat),
|
||||
"a live enchant slot naming a binding row"
|
||||
);
|
||||
assert!(
|
||||
!already_bound(&item(0x0, 13), cat),
|
||||
"a non-binding enchant leaves the item unbound"
|
||||
);
|
||||
assert!(
|
||||
!already_bound(&item(0x0, 99), cat),
|
||||
"an id that names no row binds nothing (the ref's `testl` after the table load)"
|
||||
);
|
||||
assert!(
|
||||
!already_bound(&item(0x0, 11), None),
|
||||
"no catalog loaded — the enchant half cannot answer, and does not guess"
|
||||
);
|
||||
// The one that pins the design: bound, and invisible to the line law.
|
||||
assert!(
|
||||
already_bound(&item(0x0, 12), cat),
|
||||
"the Firestone shape — binds AND hides its line"
|
||||
);
|
||||
assert!(
|
||||
enchant_lines_quiet([(0u8, 12i32, 0u32, None)], cat).is_empty(),
|
||||
"…and the rendered lines are empty for it, which is why they are not the source"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,17 @@ fn watch_glue_music(mut music: NonSendMut<GlueMusic>, time: Res<Time>, config: R
|
||||
music.watch.feed(h, f64::from(time.delta_secs()));
|
||||
}
|
||||
|
||||
/// Report the glue theme's voice into the global budget (decision 1557) — it rides `InWorld`
|
||||
/// since 1550, so it occupies a channel there like anything else.
|
||||
fn report_stream_voices(music: NonSend<GlueMusic>, mut out: NonSendMut<super::SoundOutput>) {
|
||||
out.glue_streams = usize::from(
|
||||
music
|
||||
.handle
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.state() != kira::sound::PlaybackState::Stopped),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_message::<GlueSound>()
|
||||
.insert_non_send_resource(GlueMusic::default())
|
||||
@@ -227,6 +238,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
watch_glue_music
|
||||
.after(start_glue_music)
|
||||
.after(hand_off_glue_music),
|
||||
report_stream_voices,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,17 @@ pub(crate) struct ActiveChannel {
|
||||
category: SoundCategory,
|
||||
/// The **voice bus** this channel occupies, for the concurrency cap ([`Bus`]).
|
||||
bus: Bus,
|
||||
/// Does this channel loop? A loop is a *bed* — an ambience, a tracked body loop — and the
|
||||
/// voice cap never steals one: cutting a bed leaves an audible hole that stays open, where
|
||||
/// stealing a one-shot costs at most the tail of a sound already being buried. (The reference
|
||||
/// makes no such distinction, but it does not have to: its exhaustion behaviour lives inside
|
||||
/// `fmod.dll` and is unreadable, so the policy is ours to choose — decision 1557.)
|
||||
looping: bool,
|
||||
/// This channel's **current effective amplitude** — `category · v · gain · rolloff ·
|
||||
/// near_field`, refreshed by [`pump_channels`] every frame. Cached rather than recomputed
|
||||
/// because the voice cap needs to rank every live channel by audibility on the play path,
|
||||
/// which is the one place that must not walk the world.
|
||||
amp: f32,
|
||||
/// The **voice category latched on this channel**, when this play is a unit's one-shot
|
||||
/// creature bark — the reference's `[unit+0xb24]` beside the `[unit+0xb20]` handle
|
||||
/// ([`unit_voice_playing`]). `None` for every other play, which is what keeps a unit's body
|
||||
@@ -234,6 +245,111 @@ impl Bus {
|
||||
/// ```
|
||||
const BUS_CAP: [u32; 13] = [0x7fff_ffff, 1, 2, 2, 1, 1, 2, 2, 1, 6, 4, 1, 2];
|
||||
|
||||
/// How many copies of **one kit** may sound at once, for the rows the reference leaves ungated by
|
||||
/// flag 0x20. See the gate in [`play_kit_ext`] for the measurement that set it, and decision 1560
|
||||
/// for why this is ours to add rather than a fidelity port.
|
||||
const SAME_KIT_MAX: usize = 2;
|
||||
|
||||
/// The reference's **global voice ceiling** — the number this whole hunt came down to.
|
||||
///
|
||||
/// `FSOUND_Init(44100, 12, 0x82)` (wow-re `sound/scratch/voice-cap-and-headroom.md` §5, VERIFIED
|
||||
/// on the binary at `0x7a492b`; the `SoundSoftwareChannels` CVar's own default is `"12"`).
|
||||
/// Hardware voices are a second bank of up to 12, but `FSOUND_SetMaxHardwareChannels` is **forced
|
||||
/// to 0** whenever `FSOUND_GetDriverCaps` reports no hardware mixing — which is every host this
|
||||
/// decade — so the note's conclusion is "exactly 12 on any host".
|
||||
///
|
||||
/// **This ceiling is what actually bounds the reference's mix, and we never had it.** The same
|
||||
/// note says of the mass-buff case that it plays "with no cap and no dedupe — what bounds it is
|
||||
/// the device ceiling, and nothing else". A probe capture measured benilla at **42 simultaneous
|
||||
/// voices**, over 12 for 23 % of the run, which is what made the summed mix ask for +13.4 dBFS
|
||||
/// and the limiter (1551) pull the whole mix down by up to 13.5 dB a quarter of the time. The
|
||||
/// clipping became pumping; the director heard no improvement, correctly. Decision 1557.
|
||||
///
|
||||
/// The count covers **everything the device is mixing** — music and ambience included, because
|
||||
/// the reference's bus 0 (cap `INT_MAX`) is where zone music, ambience and the liquid loops all
|
||||
/// land, and they occupy FMOD channels like anything else. See [`SoundOutput::live_voices`].
|
||||
pub(crate) const SOFTWARE_CHANNELS: usize = 12;
|
||||
|
||||
/// Make room for one more voice, or refuse. Returns `true` if the caller may start a sound.
|
||||
///
|
||||
/// Under the ceiling this is a length check and nothing else. At the ceiling it is a **steal**:
|
||||
/// the quietest live one-shot loses its slot to a newcomer that is louder than it is, and a
|
||||
/// newcomer quieter than everything already playing is simply dropped. "Keep the loudest twelve"
|
||||
/// is the rule that makes a hard ceiling sound like a mix rather than like a lottery — the
|
||||
/// alternative (refuse whatever arrives last) silences the sword swinging in your face because
|
||||
/// twelve distant footsteps got there first.
|
||||
///
|
||||
/// The reference cannot settle this for us: it passes `FSOUND_FREE` at every play site and, in
|
||||
/// wow-re's words, "never protects a voice" — so what FMOD does when all 12 are busy is inside
|
||||
/// `fmod.dll`, unreadable, and explicitly ours to decide (decision 1557).
|
||||
fn claim_voice(out: &mut SoundOutput, candidate_amp: f32) -> bool {
|
||||
if out.live_voices() >= SOFTWARE_CHANNELS {
|
||||
// Reap first. [`pump_channels`] drops finished channels once a frame, but plays happen in
|
||||
// several stages and a sound that ended earlier this frame is still in the list — so
|
||||
// without this the budget would count ghosts and refuse real sounds, intermittently and
|
||||
// in exactly the busy moments the cap is for. Only on the crowded path: the common case
|
||||
// stays a length check.
|
||||
out.channels
|
||||
.retain(|c| c.handle.state() != PlaybackState::Stopped);
|
||||
}
|
||||
let stealable = out
|
||||
.channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| !c.looping)
|
||||
.map(|(i, c)| (i, c.amp));
|
||||
match pick_voice_slot(stealable, out.live_voices(), candidate_amp) {
|
||||
VoiceSlot::Free => true,
|
||||
VoiceSlot::Steal(i) => {
|
||||
// Ending a live waveform at an arbitrary sample is a step to zero — i.e. a click, the
|
||||
// exact defect this whole area keeps producing. Fade it (decision 1026's `declick`).
|
||||
out.channels[i].handle.stop(mixer::declick());
|
||||
out.channels.swap_remove(i);
|
||||
out.voices_stolen += 1;
|
||||
true
|
||||
}
|
||||
VoiceSlot::Denied => {
|
||||
out.voices_denied += 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`claim_voice`] decided.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum VoiceSlot {
|
||||
/// Under the ceiling — just play.
|
||||
Free,
|
||||
/// At the ceiling; this channel index loses its slot.
|
||||
Steal(usize),
|
||||
/// At the ceiling and nothing live is quieter — drop the new sound.
|
||||
Denied,
|
||||
}
|
||||
|
||||
/// The voice-cap decision, pure over `(index, amplitude)` of the **stealable** (non-looping) live
|
||||
/// channels — so the policy that decides what the player does and does not hear is testable
|
||||
/// without an audio device, like [`bus_at_cap`] beside it.
|
||||
///
|
||||
/// The comparison is **strict**: a newcomer must be genuinely *louder* than the quietest thing
|
||||
/// playing to take its slot. That matters for the exact case this cap exists for — a mass buff
|
||||
/// lands five sample-identical copies at the same amplitude in one frame, and a non-strict test
|
||||
/// would let each new copy evict the previous one forever, spending the whole budget churning
|
||||
/// between identical sounds. Strict, the first ones through hold their slots and the rest are
|
||||
/// dropped, which is both cheaper and what you want to hear.
|
||||
fn pick_voice_slot(
|
||||
stealable: impl Iterator<Item = (usize, f32)>,
|
||||
live_voices: usize,
|
||||
candidate_amp: f32,
|
||||
) -> VoiceSlot {
|
||||
if live_voices < SOFTWARE_CHANNELS {
|
||||
return VoiceSlot::Free;
|
||||
}
|
||||
match stealable.min_by(|a, b| a.1.total_cmp(&b.1)) {
|
||||
Some((i, amp)) if amp < candidate_amp => VoiceSlot::Steal(i),
|
||||
_ => VoiceSlot::Denied,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `bus` already carrying its cap's worth of live channels? Pure over the live buses, so the
|
||||
/// whole gate is testable without a device (the caps are the reference's, and getting one wrong
|
||||
/// silences a class of sound outright).
|
||||
@@ -386,6 +502,28 @@ pub(super) fn play_kit_ext(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// **The coherent-copy cap** (decision 1560) — the fallback for the rows the reference leaves
|
||||
// ungated above.
|
||||
//
|
||||
// A probe capture of the director's own reported case measured the loudest moment of a
|
||||
// 71-second session at **3.73x full scale**, and it was `HolyProtection` x5 in one second with
|
||||
// only 10 voices live — so the 12-voice ceiling (1557) cannot touch it. Five copies of one
|
||||
// 0 dBFS file started in the same frame are *sample-aligned*, so they sum **coherently**: the
|
||||
// result is not five sounds, it is one sound +14 dB. That is inaudible as density and very
|
||||
// audible as distortion — the worst possible trade, and the reason a mass buff was the thing
|
||||
// the director reported first.
|
||||
//
|
||||
// The reference already caps same-kit concurrency at **1** — for the 2 776 of 4 623 rows that
|
||||
// carry flag 0x20. It simply never gated the other 1 847, and `HolyProtection` (3116,
|
||||
// `Flags 0x0000`) is one of them. So this is not a new idea imposed on the data; it is the
|
||||
// reference's own idea applied, more loosely, where the data left a hole. Two rather than one
|
||||
// deliberately: it keeps some sense that several things happened (and cannot silence two
|
||||
// genuinely distinct mobs' impacts), while removing the coherent stack that does the damage.
|
||||
if out.channels.iter().filter(|c| c.kit == id).count() >= SAME_KIT_MAX {
|
||||
out.copies_dropped += 1;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The variation: an explicit index when the caller drives the cycle (the client's
|
||||
// `variant != -1`), else the weighted pick with depletion.
|
||||
let pick = match variant {
|
||||
@@ -412,7 +550,8 @@ pub(super) fn play_kit_ext(
|
||||
let atten = d_sq.map_or(1.0, |d| {
|
||||
math::fmod_rolloff(d, min_dist) * near_field(d, cutoff)
|
||||
});
|
||||
let mut data = data.volume(mixer::amp_to_db(config.category_amp(category) * v * atten));
|
||||
let amp = config.category_amp(category) * v * atten;
|
||||
let mut data = data.volume(mixer::amp_to_db(amp));
|
||||
if flags & sound_kit_flags::VARY_PITCH != 0 {
|
||||
let draw = math::variation_draw(kits.rng.next());
|
||||
let freq = math::variation_pitch_freq(draw);
|
||||
@@ -423,6 +562,13 @@ pub(super) fn play_kit_ext(
|
||||
data = data.loop_region(..);
|
||||
}
|
||||
|
||||
// The global ceiling ([`SOFTWARE_CHANNELS`]) — last of the gates, because it is the only one
|
||||
// that can *stop another sound*, and it must not do that for a play the cheaper gates above
|
||||
// were going to drop anyway.
|
||||
if !claim_voice(out, amp) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mixer = out.mixer.as_mut().context("no audio device")?;
|
||||
let (track, handle) = match pos {
|
||||
Some(p) => {
|
||||
@@ -440,20 +586,23 @@ pub(super) fn play_kit_ext(
|
||||
// a guess at which trigger fired. `RUST_LOG=benilla_app::sound=debug` now answers it, and since
|
||||
// 1155 it also answers "…and does it take the interior's reverb?" — the `EAXDef` wet/dry class
|
||||
// is invisible in the audio itself, so a report of an unexpected echo needs it in the trace.
|
||||
debug!(
|
||||
"sound: play kit {id} ({}) {} {}",
|
||||
kits.catalog.get(id).map_or("?", |k| k.name.as_str()),
|
||||
match category {
|
||||
SoundCategory::Sfx => "sfx",
|
||||
SoundCategory::Music => "music",
|
||||
SoundCategory::Ambience => "ambience",
|
||||
},
|
||||
match pos {
|
||||
Some(_) if eax_def != 0 => "3d wet",
|
||||
Some(_) => "3d dry",
|
||||
None => "2d",
|
||||
},
|
||||
);
|
||||
let name = kits.catalog.get(id).map_or("?", |k| k.name.as_str());
|
||||
let cat = match category {
|
||||
SoundCategory::Sfx => "sfx",
|
||||
SoundCategory::Music => "music",
|
||||
SoundCategory::Ambience => "ambience",
|
||||
};
|
||||
let spatial = match pos {
|
||||
Some(_) if eax_def != 0 => "3d wet",
|
||||
Some(_) => "3d dry",
|
||||
None => "2d",
|
||||
};
|
||||
debug!("sound: play kit {id} ({name}) {cat} {spatial}");
|
||||
// …and onto the probe's timeline when one is recording (decision 1556), so a capture answers
|
||||
// "what was playing when the mix went past full scale" instead of only "it did".
|
||||
if let Some(probe) = out.probe.as_ref() {
|
||||
probe.note_play(id, name, cat, spatial);
|
||||
}
|
||||
out.channels.push(ActiveChannel {
|
||||
kit: id,
|
||||
source,
|
||||
@@ -466,6 +615,8 @@ pub(super) fn play_kit_ext(
|
||||
v,
|
||||
gain: 1.0,
|
||||
category,
|
||||
looping,
|
||||
amp,
|
||||
bus,
|
||||
voice,
|
||||
});
|
||||
@@ -602,9 +753,16 @@ pub(crate) fn play_file(
|
||||
return Ok(());
|
||||
}
|
||||
let data = kits.sfx(assets, path)?;
|
||||
let data = data.volume(mixer::amp_to_db(config.category_amp(category)));
|
||||
let amp = config.category_amp(category);
|
||||
let data = data.volume(mixer::amp_to_db(amp));
|
||||
if !claim_voice(out, amp) {
|
||||
return Ok(());
|
||||
}
|
||||
let mixer = out.mixer.as_mut().context("no audio device")?;
|
||||
let handle = mixer.play_2d(data)?;
|
||||
if let Some(probe) = out.probe.as_ref() {
|
||||
probe.note_play(0, path, "sfx", "2d");
|
||||
}
|
||||
out.channels.push(ActiveChannel {
|
||||
kit: 0,
|
||||
source: None,
|
||||
@@ -617,6 +775,8 @@ pub(crate) fn play_file(
|
||||
v: 1.0,
|
||||
gain: 1.0,
|
||||
category,
|
||||
looping: false,
|
||||
amp,
|
||||
bus: Bus::DEFAULT,
|
||||
voice: None,
|
||||
});
|
||||
@@ -767,10 +927,9 @@ pub(super) fn pump_channels(
|
||||
}
|
||||
let Some(p) = ch.pos else {
|
||||
// 2D: only the category slider can move under a live channel.
|
||||
ch.handle.set_volume(
|
||||
mixer::amp_to_db(config.category_amp(ch.category) * ch.v * ch.gain),
|
||||
mixer::glide(),
|
||||
);
|
||||
ch.amp = config.category_amp(ch.category) * ch.v * ch.gain;
|
||||
ch.handle
|
||||
.set_volume(mixer::amp_to_db(ch.amp), mixer::glide());
|
||||
return true;
|
||||
};
|
||||
let d_sq = math::dist_sq(listener, p);
|
||||
@@ -779,7 +938,7 @@ pub(super) fn pump_channels(
|
||||
ch.handle.stop(mixer::declick());
|
||||
return false;
|
||||
}
|
||||
let amp = config.category_amp(ch.category)
|
||||
ch.amp = config.category_amp(ch.category)
|
||||
* ch.v
|
||||
* ch.gain
|
||||
* math::fmod_rolloff(d_sq, ch.min_dist)
|
||||
@@ -787,7 +946,8 @@ pub(super) fn pump_channels(
|
||||
// Glides, not snaps (decision 1026): this is the per-frame gain feed, and a step here is a
|
||||
// click. It is also the one that scales — every live channel steps together when a frame
|
||||
// hitches, which is what a "crack fest" under OBS actually was.
|
||||
ch.handle.set_volume(mixer::amp_to_db(amp), mixer::glide());
|
||||
ch.handle
|
||||
.set_volume(mixer::amp_to_db(ch.amp), mixer::glide());
|
||||
true
|
||||
});
|
||||
}
|
||||
@@ -1014,4 +1174,112 @@ mod tests {
|
||||
assert!(!occupies_voice_slot(None, None, bear));
|
||||
assert!(!occupies_greeting_latch(None, None, bear));
|
||||
}
|
||||
|
||||
/// The ceiling is the reference's, not a number we liked: `FSOUND_Init(44100, 12, 0x82)`,
|
||||
/// verified on the binary at `0x7a492b` (wow-re `voice-cap-and-headroom.md` §5), with the
|
||||
/// hardware bank forced to 0 on any host without hardware mixing. Pinned so a later "let's
|
||||
/// raise it a bit" has to argue with the byte-fact rather than drift past it.
|
||||
#[test]
|
||||
fn the_ceiling_is_the_references_twelve() {
|
||||
assert_eq!(SOFTWARE_CHANNELS, 12);
|
||||
}
|
||||
|
||||
/// Under the ceiling the cap is invisible — no walk, no steal, whatever the amplitudes are.
|
||||
#[test]
|
||||
fn under_the_ceiling_everything_plays() {
|
||||
let live = [(0, 0.9f32), (1, 0.8)];
|
||||
assert_eq!(
|
||||
pick_voice_slot(live.into_iter(), SOFTWARE_CHANNELS - 1, 0.001),
|
||||
VoiceSlot::Free,
|
||||
"a near-silent sound still plays while there is room"
|
||||
);
|
||||
}
|
||||
|
||||
/// At the ceiling, the **quietest** one-shot loses — not the oldest, not the newest. This is
|
||||
/// the rule that makes a hard cap sound like a mix: the sword in your face beats twelve
|
||||
/// footsteps down the corridor, whichever started first.
|
||||
#[test]
|
||||
fn at_the_ceiling_the_quietest_one_shot_loses_to_a_louder_newcomer() {
|
||||
// Index 2 is the quietest; index 0 is the oldest and must survive.
|
||||
let live = [(0, 0.50f32), (1, 0.30), (2, 0.05), (3, 0.40)];
|
||||
assert_eq!(
|
||||
pick_voice_slot(live.into_iter(), SOFTWARE_CHANNELS, 0.9),
|
||||
VoiceSlot::Steal(2)
|
||||
);
|
||||
}
|
||||
|
||||
/// A newcomer quieter than everything playing is dropped rather than stealing. Twelve loud
|
||||
/// sounds are what the player is actually listening to; a distant thirteenth is not worth
|
||||
/// silencing one of them.
|
||||
#[test]
|
||||
fn at_the_ceiling_a_quieter_newcomer_is_dropped() {
|
||||
let live = [(0, 0.50f32), (1, 0.30), (2, 0.20)];
|
||||
assert_eq!(
|
||||
pick_voice_slot(live.into_iter(), SOFTWARE_CHANNELS, 0.10),
|
||||
VoiceSlot::Denied
|
||||
);
|
||||
}
|
||||
|
||||
/// **The mass-buff case, as arithmetic.** Prayer of Fortitude lands five sample-identical
|
||||
/// copies of one 0 dBFS file in a single frame, at identical amplitude. The comparison is
|
||||
/// strict precisely so this converges: equal-loudness newcomers are dropped instead of
|
||||
/// evicting each other in a loop that would spend the entire budget churning between
|
||||
/// indistinguishable sounds. This is the exact case the director reported.
|
||||
#[test]
|
||||
fn identical_copies_do_not_churn_the_budget() {
|
||||
let live: Vec<(usize, f32)> = (0..12).map(|i| (i, 0.7)).collect();
|
||||
assert_eq!(
|
||||
pick_voice_slot(live.iter().copied(), SOFTWARE_CHANNELS, 0.7),
|
||||
VoiceSlot::Denied,
|
||||
"an equally-loud copy must not evict its own twin"
|
||||
);
|
||||
}
|
||||
|
||||
/// A loop is a bed — an ambience, a tracked body loop — and cutting one leaves a hole that
|
||||
/// stays open, where a stolen one-shot costs at most a tail. So loops are never offered as
|
||||
/// victims, and a ceiling reached entirely by beds simply drops the newcomer.
|
||||
#[test]
|
||||
fn beds_are_never_stolen() {
|
||||
// `claim_voice` filters loops out before this point, so "only loops live" reaches the
|
||||
// decision as an empty stealable set.
|
||||
assert_eq!(
|
||||
pick_voice_slot(std::iter::empty(), SOFTWARE_CHANNELS, 1.0),
|
||||
VoiceSlot::Denied
|
||||
);
|
||||
}
|
||||
|
||||
/// **The director's reported case, as arithmetic.** Prayer of Fortitude lands `HolyProtection`
|
||||
/// on five party members inside one frame. A probe capture measured exactly that as the
|
||||
/// loudest moment of a whole session — 3.73x full scale with only 10 voices live, so the
|
||||
/// 12-voice ceiling never even applied. Sample-aligned copies of one 0 dBFS file sum
|
||||
/// *coherently*: five of them is one sound +14 dB, not five sounds.
|
||||
///
|
||||
/// The cap is on **live copies of the same kit**, so the fan-out collapses to
|
||||
/// [`SAME_KIT_MAX`] however many targets the buff had.
|
||||
#[test]
|
||||
fn a_mass_buff_collapses_to_the_same_kit_cap() {
|
||||
// What the gate sees: N already-live copies of kit 3116, asked for one more.
|
||||
let live_copies = |n: usize| n >= SAME_KIT_MAX;
|
||||
assert!(!live_copies(0), "the first copy always plays");
|
||||
assert!(
|
||||
!live_copies(1),
|
||||
"so does the second — two still reads as 'several'"
|
||||
);
|
||||
for already in SAME_KIT_MAX..=8 {
|
||||
assert!(
|
||||
live_copies(already),
|
||||
"copy {} of a five-target buff must be dropped, not stacked",
|
||||
already + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The looser cap only exists for rows the reference left ungated. A row carrying flag 0x20 is
|
||||
/// still capped at **one** by the byte-verified gate above it, and this must not loosen that.
|
||||
#[test]
|
||||
fn the_reference_no_duplicate_flag_still_wins() {
|
||||
// A const block: the relationship is a compile-time fact, not a runtime one.
|
||||
const { assert!(SAME_KIT_MAX > 1) };
|
||||
assert_eq!(sound_kit_flags::NO_DUPLICATES, 0x20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
//! 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.
|
||||
//! - **`nonfinite`** — samples that were NaN or infinite. This is the meter's own blind spot,
|
||||
//! closed deliberately (decision 1556): `f32::max` *discards* a NaN operand and `NaN > 1.0` is
|
||||
//! `false`, so a mix carrying NaN reads as flawless on `peak` and `over` alike — and sails
|
||||
//! through the limiter's `peak > CEILING` test untouched, straight into the driver. A single
|
||||
//! non-finite sample is broadband noise at whatever the hardware makes of the bit pattern,
|
||||
//! which is *also* "a speaker breaking", and no counter we had could see it. It must be tested
|
||||
//! for explicitly or not at all.
|
||||
//!
|
||||
//! 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`.
|
||||
@@ -44,6 +51,9 @@ pub(super) struct MixLevel {
|
||||
over: AtomicU64,
|
||||
/// The limiter's deepest gain since the last [`Self::take`] (1.0 = it never engaged).
|
||||
reduction_bits: AtomicU32,
|
||||
/// Non-finite (NaN/inf) samples since the last [`Self::take`]. Never nonzero in a healthy
|
||||
/// mix; any nonzero reading is a defect upstream, not a loud passage.
|
||||
nonfinite: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for MixLevel {
|
||||
@@ -52,6 +62,7 @@ impl Default for MixLevel {
|
||||
peak_bits: AtomicU32::new(0),
|
||||
over: AtomicU64::new(0),
|
||||
reduction_bits: AtomicU32::new(1.0f32.to_bits()),
|
||||
nonfinite: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,17 +76,22 @@ pub(super) struct LevelReading {
|
||||
pub(super) over: u64,
|
||||
/// The limiter's deepest gain in the window (1.0 = never engaged).
|
||||
pub(super) reduction: f32,
|
||||
/// Non-finite samples in the window. Anything but zero is a bug upstream of the mix.
|
||||
pub(super) nonfinite: u64,
|
||||
}
|
||||
|
||||
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) {
|
||||
fn block(&self, peak: f32, over: u64, nonfinite: u64) {
|
||||
self.peak_bits.fetch_max(peak.to_bits(), Ordering::Relaxed);
|
||||
if over > 0 {
|
||||
self.over.fetch_add(over, Ordering::Relaxed);
|
||||
}
|
||||
if nonfinite > 0 {
|
||||
self.nonfinite.fetch_add(nonfinite, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the limiter's applied gain in (audio thread — [`super::limiter`]).
|
||||
@@ -94,6 +110,7 @@ impl MixLevel {
|
||||
self.reduction_bits
|
||||
.swap(1.0f32.to_bits(), Ordering::Relaxed),
|
||||
),
|
||||
nonfinite: self.nonfinite.swap(0, Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,13 +144,20 @@ impl Effect for Meter {
|
||||
fn process(&mut self, input: &mut [Frame], _dt: f64, _info: &Info) {
|
||||
let mut peak = 0.0f32;
|
||||
let mut over = 0u64;
|
||||
let mut nonfinite = 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);
|
||||
// `is_finite` first: a NaN would otherwise vanish into `max` and compare `false`
|
||||
// against the over-scale test, i.e. register as a perfectly healthy sample.
|
||||
if mag.is_finite() {
|
||||
peak = peak.max(mag);
|
||||
over += u64::from(mag > 1.0);
|
||||
} else {
|
||||
nonfinite += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.level.block(peak, over);
|
||||
self.level.block(peak, over, nonfinite);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +176,7 @@ mod tests {
|
||||
vals.reverse();
|
||||
let level = MixLevel::default();
|
||||
for v in vals {
|
||||
level.block(v, 0);
|
||||
level.block(v, 0, 0);
|
||||
}
|
||||
assert_eq!(level.take().peak, 1e30);
|
||||
}
|
||||
@@ -162,9 +186,9 @@ mod tests {
|
||||
#[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.block(0.5, 0, 0);
|
||||
level.block(2.0, 2, 0);
|
||||
level.block(1.5, 0, 0);
|
||||
level.gain(0.25);
|
||||
let r = level.take();
|
||||
assert_eq!(r.peak, 2.0);
|
||||
@@ -175,4 +199,43 @@ mod tests {
|
||||
assert_eq!(r.over, 0);
|
||||
assert_eq!(r.reduction, 1.0);
|
||||
}
|
||||
|
||||
/// The blind spot this meter exists to not have. A NaN sample is invisible to both amplitude
|
||||
/// tests — `f32::max` returns the *other* operand and `NaN > 1.0` is `false` — so a mix full
|
||||
/// of NaN would otherwise report peak 0.0, zero over-scale, and perfect health while the
|
||||
/// driver plays noise. Only an explicit finiteness test sees it.
|
||||
#[test]
|
||||
fn non_finite_samples_are_counted_not_silently_swallowed() {
|
||||
// The trap, stated as an executable fact rather than a comment. `black_box` keeps these
|
||||
// runtime comparisons — a literal NaN comparison is (rightly) a lint, and the point here
|
||||
// is exactly that this is what the audio thread would have been doing.
|
||||
let nan = std::hint::black_box(f32::NAN);
|
||||
assert_eq!(0.0f32.max(nan), 0.0, "`max` discards a NaN operand");
|
||||
assert!(
|
||||
nan.partial_cmp(&1.0).is_none(),
|
||||
"a NaN is not 'over full scale' — it is unordered, so the test never fires"
|
||||
);
|
||||
|
||||
let mut meter = Meter {
|
||||
level: Arc::new(MixLevel::default()),
|
||||
};
|
||||
let level = Arc::clone(&meter.level);
|
||||
let mut block = [
|
||||
Frame::from_mono(f32::NAN),
|
||||
Frame::from_mono(0.5),
|
||||
Frame::from_mono(f32::INFINITY),
|
||||
];
|
||||
meter.process(
|
||||
&mut block,
|
||||
1.0 / 44_100.0,
|
||||
&kira::info::MockInfoBuilder::new().build(),
|
||||
);
|
||||
let r = level.take();
|
||||
assert_eq!(
|
||||
r.nonfinite, 4,
|
||||
"two non-finite frames, stereo — four samples"
|
||||
);
|
||||
assert_eq!(r.peak, 0.5, "the finite sample still sets the peak");
|
||||
assert_eq!(r.over, 0, "and a NaN is not over-scale, it is broken");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ use kira::effect::{Effect, EffectBuilder};
|
||||
use kira::info::Info;
|
||||
use kira::Frame;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Ring depth in seconds of stereo audio. Deep enough that only a wedged writer drops samples.
|
||||
const RING_SECONDS: usize = 8;
|
||||
@@ -41,19 +43,38 @@ pub(super) fn install(
|
||||
builder: kira::track::MainTrackBuilder,
|
||||
sample_rate: Option<u32>,
|
||||
) -> kira::track::MainTrackBuilder {
|
||||
let Some(path) = std::env::var_os("WOW_MIX_TAP") else {
|
||||
return builder;
|
||||
};
|
||||
let Some(sample_rate) = sample_rate else {
|
||||
warn!("mix tap: device sample rate unknown — not recording");
|
||||
return builder;
|
||||
};
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let file = match std::fs::File::create(&path) {
|
||||
let mut builder = builder;
|
||||
if let Some(path) = std::env::var_os("WOW_MIX_TAP") {
|
||||
if let Some(rate) = sample_rate {
|
||||
install_at(&mut builder, std::path::Path::new(&path), rate);
|
||||
} else {
|
||||
warn!("mix tap: device sample rate unknown — not recording");
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
/// Install a tap writing `path`, and hand back its **frame clock**: a running count of the frames
|
||||
/// this tap has processed, i.e. the exact write position in the file it is producing.
|
||||
///
|
||||
/// The clock is what makes a capture *correlatable*. A WAV on its own answers "what did the mix
|
||||
/// sound like"; it cannot answer "…at the moment the director pressed the key", because the game
|
||||
/// clock and the device clock are different clocks that drift. Reading this counter from a game
|
||||
/// system converts a game-thread event into a sample offset in the file, so a mark, a kit start
|
||||
/// and a missed deadline all land on the waveform they belong to (decision 1556).
|
||||
///
|
||||
/// Returns `None` if the file cannot be created — a probe that cannot record says so and the run
|
||||
/// continues, rather than taking the client down over an instrument.
|
||||
pub(super) fn install_at(
|
||||
builder: &mut kira::track::MainTrackBuilder,
|
||||
path: &std::path::Path,
|
||||
sample_rate: u32,
|
||||
) -> Option<Arc<AtomicU64>> {
|
||||
let file = match std::fs::File::create(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("mix tap: cannot create {}: {e}", path.display());
|
||||
return builder;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (producer, consumer) = rtrb::RingBuffer::new(sample_rate as usize * 2 * RING_SECONDS);
|
||||
@@ -61,14 +82,18 @@ pub(super) fn install(
|
||||
.name("mix-tap".into())
|
||||
.spawn(move || writer(file, consumer, sample_rate))
|
||||
.expect("spawn mix-tap writer");
|
||||
info!("mix tap: recording the main mix to {}", path.display());
|
||||
let mut builder = builder;
|
||||
builder.add_effect(TapBuilder { producer });
|
||||
builder
|
||||
info!("mix tap: recording to {}", path.display());
|
||||
let frames = Arc::new(AtomicU64::new(0));
|
||||
builder.add_effect(TapBuilder {
|
||||
producer,
|
||||
frames: Arc::clone(&frames),
|
||||
});
|
||||
Some(frames)
|
||||
}
|
||||
|
||||
struct TapBuilder {
|
||||
producer: rtrb::Producer<f32>,
|
||||
frames: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl EffectBuilder for TapBuilder {
|
||||
@@ -77,6 +102,7 @@ impl EffectBuilder for TapBuilder {
|
||||
(
|
||||
Box::new(Tap {
|
||||
producer: self.producer,
|
||||
frames: self.frames,
|
||||
dropped: 0,
|
||||
}),
|
||||
(),
|
||||
@@ -88,6 +114,8 @@ impl EffectBuilder for TapBuilder {
|
||||
/// thing this must never do is block or allocate on the render path.
|
||||
struct Tap {
|
||||
producer: rtrb::Producer<f32>,
|
||||
/// Frames pushed so far — the file's write position, published for the game thread to read.
|
||||
frames: Arc<AtomicU64>,
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
@@ -98,6 +126,8 @@ impl Effect for Tap {
|
||||
self.dropped += 1;
|
||||
}
|
||||
}
|
||||
// Published after the push so a reader never names a frame the file does not yet hold.
|
||||
self.frames.fetch_add(input.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! 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::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -137,6 +138,8 @@ pub(crate) struct Mixer {
|
||||
level: Arc<MixLevel>,
|
||||
/// The `SoundOutputLimiter` CVar cell the limiter reads each block.
|
||||
limiter_on: Arc<AtomicBool>,
|
||||
/// The pre-limiter tap's frame clock while a probing run records; `None` otherwise.
|
||||
audio_pos: Option<Arc<AtomicU64>>,
|
||||
/// 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>,
|
||||
@@ -145,13 +148,15 @@ pub(crate) struct Mixer {
|
||||
impl Mixer {
|
||||
/// Open the default audio device. Fails cleanly when there is none (headless/CI) — the caller
|
||||
/// runs silent with `None` (mirrors the client's `-nosound` gate).
|
||||
pub(crate) fn new() -> Result<Self> {
|
||||
pub(crate) fn new(probe_dir: Option<&Path>) -> Result<Self> {
|
||||
let (backend_settings, sample_rate) = backend_settings();
|
||||
let level = Arc::new(MixLevel::default());
|
||||
let limiter_on = Arc::new(AtomicBool::new(true));
|
||||
let (main_track_builder, audio_pos) =
|
||||
main_track(&level, &limiter_on, sample_rate, probe_dir);
|
||||
let settings = AudioManagerSettings::<DefaultBackend> {
|
||||
backend_settings,
|
||||
main_track_builder: main_track(&level, &limiter_on, sample_rate),
|
||||
main_track_builder,
|
||||
capacities: kira::Capacities {
|
||||
sub_track_capacity: SPATIAL_VOICE_CAPACITY,
|
||||
..Default::default()
|
||||
@@ -198,9 +203,16 @@ impl Mixer {
|
||||
level,
|
||||
limiter_on,
|
||||
sample_rate,
|
||||
audio_pos,
|
||||
})
|
||||
}
|
||||
|
||||
/// The probing run's shared time axis — the pre-limiter tap's frame clock
|
||||
/// ([`super::probe`]). `None` when not probing.
|
||||
pub(super) fn audio_pos(&self) -> Option<Arc<AtomicU64>> {
|
||||
self.audio_pos.clone()
|
||||
}
|
||||
|
||||
/// Apply a zone reverb preset — the seam mirror of `FSOUND_Reverb_SetProperties` (the real
|
||||
/// client marshals the `SoundProviderPreferences` row into EAX listener properties and hands
|
||||
/// them to FMOD; wow-re `0x45a790`/`0x7a5fa0`). `None` = no preset → wet to silence (the
|
||||
@@ -392,15 +404,24 @@ fn main_track(
|
||||
level: &Arc<MixLevel>,
|
||||
limiter_on: &Arc<AtomicBool>,
|
||||
sample_rate: Option<u32>,
|
||||
) -> kira::track::MainTrackBuilder {
|
||||
probe_dir: Option<&Path>,
|
||||
) -> (kira::track::MainTrackBuilder, Option<Arc<AtomicU64>>) {
|
||||
let mut main = kira::track::MainTrackBuilder::new();
|
||||
meter::install(&mut main, level);
|
||||
// A probing run brackets the limiter with two taps (decision 1556). One tap can only ever
|
||||
// say what was heard; two say whether the limiter is the thing that changed it — which is
|
||||
// precisely the question a "your fix changed nothing" report asks, and one the post-limiter
|
||||
// tap alone provably cannot answer.
|
||||
let probe = probe_dir.zip(sample_rate);
|
||||
let audio_pos = probe
|
||||
.and_then(|(dir, rate)| super::mix_tap::install_at(&mut main, &dir.join("pre.wav"), rate));
|
||||
limiter::install(&mut main, level, limiter_on);
|
||||
super::mix_tap::install(main, sample_rate)
|
||||
if let Some((dir, rate)) = probe {
|
||||
super::mix_tap::install_at(&mut main, &dir.join("post.wav"), rate);
|
||||
}
|
||||
(super::mix_tap::install(main, sample_rate), audio_pos)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// its own, and that carries `BufferSize::Default` — whatever CoreAudio happens to hand us. On
|
||||
/// macOS the buffer size is a *shared, per-device* property, so another app (an OBS capture, a
|
||||
@@ -814,7 +835,7 @@ mod tests {
|
||||
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));
|
||||
let (mut main, _) = main_track(&asked, &limiter_on, Some(RATE), None);
|
||||
meter::install(&mut main, &heard);
|
||||
let mut manager = AudioManager::<MockBackend>::new(AudioManagerSettings {
|
||||
backend_settings: MockBackendSettings { sample_rate: RATE },
|
||||
@@ -848,6 +869,117 @@ mod tests {
|
||||
(asked, heard.peak, heard.over)
|
||||
}
|
||||
|
||||
/// The probe's two taps must **bracket** the limiter (decision 1556) — and this is the test
|
||||
/// that the capture handed to the director can actually tell the mechanisms apart.
|
||||
///
|
||||
/// The whole reason for a second tap is that `post.wav` alone cannot distinguish "the mix
|
||||
/// never clipped" from "the limiter failed to hold it". Both produce a clean post file for
|
||||
/// entirely different reasons, and only the pre file separates them. So: render an
|
||||
/// over-scale mix through the real chain with the probe armed, and assert the two files
|
||||
/// disagree in exactly the way the diagnosis depends on — `pre.wav` far past full scale,
|
||||
/// `post.wav` held under it. If these two ever agree, the instrument has gone blind and every
|
||||
/// verdict it prints is worthless.
|
||||
///
|
||||
/// Writes into the OS temp dir (never the install, never `benilla-config`) and leaves the
|
||||
/// capture behind on purpose: `scripts/soundprobe.py $TMPDIR/benilla-probe-selftest` is then
|
||||
/// a live check of the analyser against a capture with a known answer.
|
||||
#[test]
|
||||
fn the_probe_taps_bracket_the_limiter() {
|
||||
use kira::backend::mock::{MockBackend, MockBackendSettings};
|
||||
|
||||
const RATE: u32 = 44_100;
|
||||
const COPIES: usize = 5;
|
||||
|
||||
let dir = std::env::temp_dir().join("benilla-probe-selftest");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp capture dir");
|
||||
|
||||
// A 0 dBFS tone, in memory — the shape every WoW SFX ships in (mastered to full scale),
|
||||
// without needing the gitignored install for a test about the *chain*.
|
||||
let frames = RATE as usize / 4;
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(36 + frames as u32 * 8).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&3u16.to_le_bytes());
|
||||
wav.extend_from_slice(&2u16.to_le_bytes());
|
||||
wav.extend_from_slice(&RATE.to_le_bytes());
|
||||
wav.extend_from_slice(&(RATE * 8).to_le_bytes());
|
||||
wav.extend_from_slice(&8u16.to_le_bytes());
|
||||
wav.extend_from_slice(&32u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&(frames as u32 * 8).to_le_bytes());
|
||||
for i in 0..frames {
|
||||
let v = (i as f32 * 440.0 * std::f32::consts::TAU / RATE as f32).sin();
|
||||
wav.extend_from_slice(&v.to_le_bytes());
|
||||
wav.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
{
|
||||
let level = Arc::new(MixLevel::default());
|
||||
let limiter_on = Arc::new(AtomicBool::new(true));
|
||||
let (main, audio_pos) = main_track(&level, &limiter_on, Some(RATE), Some(&dir));
|
||||
let audio_pos = audio_pos.expect("the pre-tap publishes a frame clock");
|
||||
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(wav).expect("tone decodes");
|
||||
for _ in 0..COPIES {
|
||||
manager.play(data.clone()).expect("play");
|
||||
}
|
||||
let blocks = (data.duration().as_secs_f64() + 0.1) * f64::from(RATE) / 128.0;
|
||||
let backend = manager.backend_mut();
|
||||
for _ in 0..(blocks.ceil() as usize) {
|
||||
backend.on_start_processing();
|
||||
backend.process();
|
||||
}
|
||||
assert!(
|
||||
audio_pos.load(Ordering::Relaxed) > 0,
|
||||
"the shared clock must advance — a capture with a dead clock cannot place a mark"
|
||||
);
|
||||
} // manager dropped: the tap producers abandon, the writers do their final flush.
|
||||
|
||||
// The writers wake on a 250 ms cadence; give them room to drain and exit.
|
||||
std::thread::sleep(std::time::Duration::from_millis(900));
|
||||
|
||||
let peak = |name: &str| -> (f32, u64) {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(bytes.len() > 44, "{name} has no audio");
|
||||
let (mut peak, mut over) = (0.0f32, 0u64);
|
||||
for c in bytes[44..].chunks_exact(4) {
|
||||
let v = f32::from_le_bytes(c.try_into().unwrap()).abs();
|
||||
peak = peak.max(v);
|
||||
over += u64::from(v > 1.0);
|
||||
}
|
||||
(peak, over)
|
||||
};
|
||||
let (pre_peak, pre_over) = peak("pre.wav");
|
||||
let (post_peak, post_over) = peak("post.wav");
|
||||
eprintln!(
|
||||
"probe self-test: pre {pre_peak:.2}x ({pre_over} over) -> post {post_peak:.2}x \
|
||||
({post_over} over); capture left at {}",
|
||||
dir.display()
|
||||
);
|
||||
|
||||
assert!(
|
||||
pre_peak > 3.0,
|
||||
"pre.wav must record what the game ASKED for — {COPIES} full-scale copies, got \
|
||||
{pre_peak:.2}x. A pre tap that already shows a limited signal is measuring the \
|
||||
wrong side of the chain."
|
||||
);
|
||||
assert!(pre_over > 0, "and it must show the over-scale samples");
|
||||
assert!(
|
||||
post_peak <= 1.0 && post_over == 0,
|
||||
"post.wav must record what was HEARD — held under full scale, got {post_peak:.2}x \
|
||||
with {post_over} 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
|
||||
|
||||
@@ -34,6 +34,7 @@ mod mix_tap;
|
||||
mod mixer;
|
||||
mod money;
|
||||
mod mount;
|
||||
mod probe;
|
||||
mod reverb;
|
||||
mod sheathe;
|
||||
mod spell;
|
||||
@@ -187,6 +188,34 @@ pub(crate) struct SoundOutput {
|
||||
pub(crate) mixer: Option<Mixer>,
|
||||
/// Live kit channels, owned and pumped by [`kit::pump_channels`].
|
||||
pub(crate) channels: Vec<kit::ActiveChannel>,
|
||||
/// The measuring-mode recorder, when `$WOW_SOUND_PROBE` armed one (decision 1556). It rides
|
||||
/// here rather than in a resource of its own so the kit player — which already holds `out` —
|
||||
/// can stamp every play on the capture's timeline with no new plumbing.
|
||||
pub(crate) probe: Option<probe::Probe>,
|
||||
/// Live **stream** voices, reported by their owners each frame ([`zone`], [`glue`]).
|
||||
///
|
||||
/// These count against the same ceiling as everything else ([`kit::SOFTWARE_CHANNELS`]): the
|
||||
/// reference's music, ambience and liquid loops all land on its uncapped bus 0 and occupy
|
||||
/// FMOD channels exactly like a sword swing does. Two fields rather than one counter because
|
||||
/// each owner **rewrites its own** every frame from its own live handles — a shared counter
|
||||
/// with two writers drifts the first time a fade is interrupted, and a voice budget that
|
||||
/// drifts is worse than none.
|
||||
pub(crate) zone_streams: usize,
|
||||
pub(crate) glue_streams: usize,
|
||||
/// One-shots that lost their slot to a louder newcomer, and plays refused because nothing
|
||||
/// live was quieter than them (decision 1557). Reported by the probe.
|
||||
pub(crate) voices_stolen: u64,
|
||||
pub(crate) voices_denied: u64,
|
||||
/// Same-kit copies dropped by [`kit::SAME_KIT_MAX`] (decision 1560).
|
||||
pub(crate) copies_dropped: u64,
|
||||
}
|
||||
|
||||
impl SoundOutput {
|
||||
/// Everything the device is currently mixing — kit channels plus the held streams. This is
|
||||
/// the number [`kit::SOFTWARE_CHANNELS`] bounds.
|
||||
pub(crate) fn live_voices(&self) -> usize {
|
||||
self.channels.len() + self.zone_streams + self.glue_streams
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3D-audio listener pose for this frame — the single authority every sound system reads, in
|
||||
@@ -244,11 +273,19 @@ impl Plugin for SoundPlugin {
|
||||
let silent = ["WOW_NOSOUND", "WOW_CAPTURE"]
|
||||
.into_iter()
|
||||
.find(|v| std::env::var_os(v).is_some());
|
||||
// Resolved before the mixer: the probe's taps are main-track effects and a kira main
|
||||
// track is build-time-only, so "are we recording?" has to be answered before the device
|
||||
// opens, not when the director presses the key.
|
||||
let probe_dir = if silent.is_some() {
|
||||
None
|
||||
} else {
|
||||
probe::output_dir()
|
||||
};
|
||||
let mixer = if let Some(var) = silent {
|
||||
info!("${var} set — audio disabled");
|
||||
None
|
||||
} else {
|
||||
match Mixer::new() {
|
||||
match Mixer::new(probe_dir.as_deref()) {
|
||||
Ok(m) => Some(m),
|
||||
Err(e) => {
|
||||
warn!("no audio device — running silent: {e:#}");
|
||||
@@ -256,9 +293,22 @@ impl Plugin for SoundPlugin {
|
||||
}
|
||||
}
|
||||
};
|
||||
let probe = probe_dir.zip(mixer.as_ref()).and_then(|(dir, m)| {
|
||||
let Some(rate) = m.sample_rate() else {
|
||||
warn!("sound probe: device sample rate unknown — not recording");
|
||||
return None;
|
||||
};
|
||||
Some(probe::Probe::start(dir, rate, m.audio_pos()))
|
||||
});
|
||||
app.insert_non_send_resource(SoundOutput {
|
||||
mixer,
|
||||
channels: Vec::new(),
|
||||
probe,
|
||||
zone_streams: 0,
|
||||
glue_streams: 0,
|
||||
voices_stolen: 0,
|
||||
voices_denied: 0,
|
||||
copies_dropped: 0,
|
||||
})
|
||||
.init_resource::<SoundConfig>()
|
||||
.init_resource::<AudioListener>()
|
||||
@@ -278,6 +328,7 @@ impl Plugin for SoundPlugin {
|
||||
hal_overload::poll,
|
||||
),
|
||||
);
|
||||
probe::plugin(app);
|
||||
kit::plugin(app);
|
||||
liquid_loop::plugin(app);
|
||||
zone::plugin(app);
|
||||
@@ -397,6 +448,13 @@ fn poll_mix_health(
|
||||
mut last_refused: Local<u64>,
|
||||
mut peak_voices: Local<usize>,
|
||||
) {
|
||||
// While a probing run records, it owns the meters: [`meter::MixLevel::take`] is
|
||||
// reset-on-read, so two consumers would each see a fraction of the truth and both would
|
||||
// under-report. The probe says everything this says, twenty times a second and to a file
|
||||
// (decision 1556).
|
||||
if out.probe.is_some() {
|
||||
return;
|
||||
}
|
||||
*peak_voices = (*peak_voices).max(out.channels.len());
|
||||
let Some(mixer) = out.mixer.as_mut() else {
|
||||
return;
|
||||
@@ -446,6 +504,17 @@ fn poll_mix_health(
|
||||
/// 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>) {
|
||||
// Ahead of the level story on purpose: a non-finite sample is not a loud mix, it is a broken
|
||||
// one, and it is invisible to every other counter we have — including the limiter's own
|
||||
// `peak > CEILING` test, which a NaN passes straight through into the driver (see [`meter`]).
|
||||
if level.nonfinite > 0 {
|
||||
error!(
|
||||
"audio: {} non-finite (NaN/inf) sample(s) reached the mix. This is a defect upstream \
|
||||
of the output — the limiter cannot catch it, and it is broadband noise at whatever \
|
||||
the hardware makes of the bits.",
|
||||
level.nonfinite,
|
||||
);
|
||||
}
|
||||
if level.over == 0 {
|
||||
debug!(
|
||||
"audio: mix peak {:.2} of full scale, {voices} voice(s) at most",
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! **Measuring mode** — `$WOW_SOUND_PROBE`: record a session so a sound report becomes evidence.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! The output limiter (decision 1551) was built, measured, and proven offline — five sample-aligned
|
||||
//! copies of a 0 dBFS buff go from `5.00x` full scale and 28 524 clipped samples to `0.99x` and
|
||||
//! none. It shipped on by default. The director then played a real session and reported **no
|
||||
//! audible change**: the mix still "gets really dirty, like a speaker breaking".
|
||||
//!
|
||||
//! That gap is the whole reason for this module. Per the contract, what the director hears
|
||||
//! outranks any mechanism we found: a verified fix that does not change what they hear means the
|
||||
//! mechanism we verified is not the mechanism they are hearing. Being right about *a* cause is not
|
||||
//! being right about *the* cause, and the honest next move is not another fix — it is an
|
||||
//! instrument that can tell the candidates apart in a **real run**, on their machine, on the
|
||||
//! encounter they are describing.
|
||||
//!
|
||||
//! The candidates are genuinely different failures that sound alike through a speaker:
|
||||
//!
|
||||
//! | mechanism | signature in a probe capture |
|
||||
//! |---|---|
|
||||
//! | over-scale sum (1551's claim) | `pre.wav` past ±1.0, `post.wav` clean, `gain` well under 1 |
|
||||
//! | **limiter not actually engaging** | `pre.wav` past ±1.0 **and** `post.wav` past ±1.0 |
|
||||
//! | buffer underrun / missed deadline | both WAVs clean at the mark; `load >= 1.0`, `overruns` climbing |
|
||||
//! | starved stream decoder | both WAVs clean; a hard step to zero in `post.wav` at the mark |
|
||||
//! | **non-finite samples** | `nan > 0` — invisible to every other counter (see [`super::meter`]) |
|
||||
//! | voice-steal / refusal clicks | `refused` climbing, a discontinuity at the mark |
|
||||
//! | the limiter's *own* pumping | `post.wav` clean but `gain` diving repeatedly — a fix that is itself the complaint |
|
||||
//!
|
||||
//! Note the last row. An instrument that can only confirm its author's fix is not an instrument;
|
||||
//! this one is built so that "1551 made it worse" is a reading it can produce.
|
||||
//!
|
||||
//! ## What a run produces
|
||||
//!
|
||||
//! Three files in `benilla-config/sound-probe/`, all written off the game thread:
|
||||
//!
|
||||
//! - **`pre.wav`** — the summed mix *as the game asked for it*, tapped ahead of the limiter.
|
||||
//! - **`post.wav`** — what was actually heard, tapped after it.
|
||||
//! - **`timeline.jsonl`** — the game-side story on a shared clock: a row every
|
||||
//! [`TICK`] with level/health/voice counts, plus an event per kit start, per marker keypress,
|
||||
//! and per refusal.
|
||||
//!
|
||||
//! Two taps rather than one is the point: with only the post-limiter tap the 1551 hunt could not
|
||||
//! have distinguished "the mix never clipped" from "the limiter failed", which is exactly the
|
||||
//! question the director's report raises.
|
||||
//!
|
||||
//! ## The shared clock
|
||||
//!
|
||||
//! A capture is only useful if a game-thread event can be placed on the waveform. The game clock
|
||||
//! and the device clock are different clocks and they drift, so every row carries `a` — the frame
|
||||
//! index published by the pre-tap ([`super::mix_tap::install_at`]), i.e. the exact sample offset
|
||||
//! in `pre.wav`. A marker is then a *sample position*, not an approximate timestamp.
|
||||
//!
|
||||
//! ## The marker
|
||||
//!
|
||||
//! **F9** stamps the timeline the instant the director hears something. This is the piece that
|
||||
//! makes a ten-minute session tractable: instead of scanning the whole capture for anomalies and
|
||||
//! guessing which one they meant, the analysis starts at the marks and reads outward. The key is
|
||||
//! bound *only* while probing, so it cannot collide with a game binding by construction.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::SoundOutput;
|
||||
|
||||
/// How often a tick row is written. 50 ms is fine enough to see a burst rise and fall inside one
|
||||
/// spell cast, and coarse enough that a ten-minute session is ~12 000 rows rather than a file
|
||||
/// nobody opens.
|
||||
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
|
||||
/// How often the run prints a spoken summary to the console, so the director gets live feedback
|
||||
/// during the session and not only a file afterwards. Matches the normal health cadence.
|
||||
const SPEAK: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// The marker key. Bound only while probing (see the module docs), so it needs no dev chord and
|
||||
/// cannot collide with a game binding.
|
||||
const MARK_KEY: KeyCode = KeyCode::F9;
|
||||
|
||||
/// Resolve the probe's output directory from `$WOW_SOUND_PROBE`, creating it.
|
||||
///
|
||||
/// `1`/`true`/`yes`/`on` means "the default place"; anything else is taken as an explicit path, so
|
||||
/// a run can be pointed at a scratch dir. The default resolves through [`crate::local_state`] like
|
||||
/// every other thing we persist — a probe is local state, and the install stays read-only
|
||||
/// (decision 1486).
|
||||
///
|
||||
/// Called *before* the mixer is built, because the taps are main-track effects and a kira main
|
||||
/// track is build-time-only.
|
||||
pub(super) fn output_dir() -> Option<PathBuf> {
|
||||
let raw = std::env::var_os("WOW_SOUND_PROBE")?;
|
||||
let raw = raw.to_string_lossy().into_owned();
|
||||
if raw.is_empty() || raw == "0" || raw == "false" || raw == "off" {
|
||||
return None;
|
||||
}
|
||||
let dir = if matches!(raw.as_str(), "1" | "true" | "yes" | "on") {
|
||||
match crate::local_state::home() {
|
||||
Some(h) => h.join("sound-probe"),
|
||||
None => {
|
||||
warn!(
|
||||
"sound probe: no benilla-config folder to write into (hermetic run) — set \
|
||||
WOW_SOUND_PROBE=<dir> to name one. Not recording."
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PathBuf::from(raw)
|
||||
};
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
warn!(
|
||||
"sound probe: cannot create {}: {e} — not recording",
|
||||
dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// The game-thread half of a probing run. Lives in [`SoundOutput`] so every sound system that
|
||||
/// already holds `out` can stamp an event without new plumbing.
|
||||
pub(crate) struct Probe {
|
||||
dir: PathBuf,
|
||||
rate: u32,
|
||||
/// Lines to the writer thread. crossbeam because the sender must be `Sync` to sit in a
|
||||
/// resource, and because a disk write on the game thread would stall the frame — and a
|
||||
/// stalled frame is *itself* one of the mechanisms under investigation. The instrument must
|
||||
/// not manufacture the artifact it is measuring.
|
||||
tx: crossbeam_channel::Sender<String>,
|
||||
/// The pre-tap's frame clock — the shared time axis (see the module docs).
|
||||
audio_pos: Option<Arc<AtomicU64>>,
|
||||
started: std::time::Instant,
|
||||
marks: u32,
|
||||
since_tick: std::time::Duration,
|
||||
since_speak: std::time::Duration,
|
||||
/// Maxima since the last spoken summary, so the console line covers its whole window even
|
||||
/// though the meters are drained twenty times a second.
|
||||
window: Window,
|
||||
/// Cumulative counters at the last tick, to report deltas.
|
||||
last_overruns: u64,
|
||||
last_refused: u64,
|
||||
last_errors: u64,
|
||||
last_stolen: u64,
|
||||
last_denied: u64,
|
||||
last_copies: u64,
|
||||
}
|
||||
|
||||
/// The accumulators behind one spoken summary.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Window {
|
||||
peak: f32,
|
||||
over: u64,
|
||||
nonfinite: u64,
|
||||
gain: f32,
|
||||
load: f32,
|
||||
voices: usize,
|
||||
overruns: u64,
|
||||
refused: u64,
|
||||
stolen: u64,
|
||||
denied: u64,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// Begin recording. `audio_pos` is the pre-tap's frame clock; `None` (no pre-tap) still
|
||||
/// produces a usable timeline, just one keyed on the game clock alone.
|
||||
pub(super) fn start(dir: PathBuf, rate: u32, audio_pos: Option<Arc<AtomicU64>>) -> Self {
|
||||
let (tx, rx) = crossbeam_channel::unbounded::<String>();
|
||||
let path = dir.join("timeline.jsonl");
|
||||
match std::fs::File::create(&path) {
|
||||
Ok(file) => {
|
||||
std::thread::Builder::new()
|
||||
.name("sound-probe".into())
|
||||
.spawn(move || writer(file, rx))
|
||||
.expect("spawn sound-probe writer");
|
||||
}
|
||||
Err(e) => warn!("sound probe: cannot create {}: {e}", path.display()),
|
||||
}
|
||||
let probe = Self {
|
||||
dir,
|
||||
rate,
|
||||
tx,
|
||||
audio_pos,
|
||||
started: std::time::Instant::now(),
|
||||
marks: 0,
|
||||
since_tick: std::time::Duration::ZERO,
|
||||
since_speak: std::time::Duration::ZERO,
|
||||
window: Window {
|
||||
gain: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
last_overruns: 0,
|
||||
last_refused: 0,
|
||||
last_errors: 0,
|
||||
last_stolen: 0,
|
||||
last_denied: 0,
|
||||
last_copies: 0,
|
||||
};
|
||||
probe.send(format!(
|
||||
r#"{{"t":0.000,"a":0,"ev":"meta","rate":{rate},"tick":{}}}"#,
|
||||
TICK.as_secs_f64()
|
||||
));
|
||||
info!(
|
||||
"── sound probe ARMED ──────────────────────────────────────────────\n\
|
||||
recording to {}\n\
|
||||
pre.wav the mix as the game asked for it (before the limiter)\n\
|
||||
post.wav what you actually heard (after it)\n\
|
||||
timeline.jsonl levels, voices, deadline misses, every kit start\n\
|
||||
PRESS F9 THE MOMENT YOU HEAR IT — that stamps the exact sample, so I read\n\
|
||||
outward from your mark instead of guessing which of ten minutes you meant.\n\
|
||||
Play until it happens a few times, then quit normally.\n\
|
||||
──────────────────────────────────────────────────────────────────",
|
||||
probe.dir.display()
|
||||
);
|
||||
probe
|
||||
}
|
||||
|
||||
/// Frames the pre-tap has written — the sample offset every row is keyed on.
|
||||
fn audio_frame(&self) -> u64 {
|
||||
self.audio_pos
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn send(&self, line: String) {
|
||||
// A full/closed channel means the writer died; the run must not.
|
||||
let _ = self.tx.send(line);
|
||||
}
|
||||
|
||||
/// Stamp an event with `extra` already formatted as JSON object members (leading comma).
|
||||
fn event(&self, ev: &str, extra: &str) {
|
||||
self.send(format!(
|
||||
r#"{{"t":{:.3},"a":{},"ev":"{ev}"{extra}}}"#,
|
||||
self.started.elapsed().as_secs_f64(),
|
||||
self.audio_frame(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Note one kit actually starting — called from the kit player's play path, so the timeline
|
||||
/// says *what* made the noise, not just that the level moved (decision 1556). Cheap enough to
|
||||
/// sit on the play path unconditionally: a channel send behind an `Option` check.
|
||||
pub(super) fn note_play(&self, kit: u32, name: &str, category: &str, spatial: &str) {
|
||||
self.event(
|
||||
"play",
|
||||
&format!(
|
||||
r#","kit":{kit},"name":"{}","cat":"{category}","sp":"{spatial}""#,
|
||||
esc(name)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-escape the only free-form text a row carries (a kit name out of the DBC).
|
||||
fn esc(s: &str) -> String {
|
||||
s.chars()
|
||||
.flat_map(|c| match c {
|
||||
'"' => vec!['\\', '"'],
|
||||
'\\' => vec!['\\', '\\'],
|
||||
c if (c as u32) < 0x20 => vec![' '],
|
||||
c => vec![c],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain lines to disk, flushing each batch so a hard kill loses at most one batch — the same
|
||||
/// crash-safety rule the mix tap follows, for the same reason: the interesting moment is often
|
||||
/// the last thing before the session ends.
|
||||
fn writer(file: std::fs::File, rx: crossbeam_channel::Receiver<String>) {
|
||||
use std::io::Write;
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
while let Ok(first) = rx.recv() {
|
||||
let _ = writeln!(file, "{first}");
|
||||
while let Ok(next) = rx.try_recv() {
|
||||
let _ = writeln!(file, "{next}");
|
||||
}
|
||||
let _ = file.flush();
|
||||
}
|
||||
let _ = file.flush();
|
||||
}
|
||||
|
||||
/// The probe's own health pump: one tick row per [`TICK`], one spoken summary per [`SPEAK`].
|
||||
///
|
||||
/// While this runs it **owns the meters** — [`super::poll_mix_health`] stands down, because
|
||||
/// [`super::meter::MixLevel::take`] is reset-on-read and two consumers would each see a fraction
|
||||
/// of the truth. Everything that report says, this says, twenty times a second and to a file.
|
||||
pub(super) fn tick(
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
time: Res<Time>,
|
||||
mut exit: MessageReader<bevy::app::AppExit>,
|
||||
) {
|
||||
let exiting = exit.read().next().is_some();
|
||||
// Read before the split borrow: the ceiling bounds *everything the device mixes*, streams
|
||||
// included, so that — not the kit-channel count — is the number worth recording.
|
||||
let voices = out.live_voices();
|
||||
let (stolen, denied) = (out.voices_stolen, out.voices_denied);
|
||||
let copies = out.copies_dropped;
|
||||
let SoundOutput { mixer, probe, .. } = &mut *out;
|
||||
let (Some(probe), Some(mixer)) = (probe.as_mut(), mixer.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
probe.window.voices = probe.window.voices.max(voices);
|
||||
probe.since_tick += time.delta();
|
||||
probe.since_speak += time.delta();
|
||||
if probe.since_tick < TICK && !exiting {
|
||||
return;
|
||||
}
|
||||
probe.since_tick = std::time::Duration::ZERO;
|
||||
|
||||
let health = mixer.poll_health();
|
||||
let level = mixer.take_level();
|
||||
let load = mixer.take_health_peak();
|
||||
let d_over = health.overruns - probe.last_overruns;
|
||||
let d_refused = health.voices_refused - probe.last_refused;
|
||||
let d_errors = health.stream_errors - probe.last_errors;
|
||||
let d_stolen = stolen - probe.last_stolen;
|
||||
let d_denied = denied - probe.last_denied;
|
||||
let d_copies = copies - probe.last_copies;
|
||||
probe.last_overruns = health.overruns;
|
||||
probe.last_refused = health.voices_refused;
|
||||
probe.last_errors = health.stream_errors;
|
||||
probe.last_stolen = stolen;
|
||||
probe.last_denied = denied;
|
||||
probe.last_copies = copies;
|
||||
|
||||
probe.event(
|
||||
"tick",
|
||||
&format!(
|
||||
r#","peak":{:.4},"over":{},"gain":{:.4},"nan":{},"load":{:.3},"voices":{voices},"overruns":{d_over},"refused":{d_refused},"errors":{d_errors},"stolen":{d_stolen},"denied":{d_denied},"copies":{d_copies}"#,
|
||||
level.peak, level.over, level.reduction, level.nonfinite, load,
|
||||
),
|
||||
);
|
||||
|
||||
// Fold into the spoken window.
|
||||
let w = &mut probe.window;
|
||||
w.peak = w.peak.max(level.peak);
|
||||
w.over += level.over;
|
||||
w.nonfinite += level.nonfinite;
|
||||
w.gain = w.gain.min(level.reduction);
|
||||
w.load = w.load.max(load);
|
||||
w.overruns += d_over;
|
||||
w.refused += d_refused;
|
||||
w.stolen += d_stolen;
|
||||
w.denied += d_denied;
|
||||
|
||||
if d_refused > 0 {
|
||||
probe.event("refused", &format!(r#","n":{d_refused}"#));
|
||||
}
|
||||
if d_errors > 0 {
|
||||
probe.event("stream_error", &format!(r#","n":{d_errors}"#));
|
||||
}
|
||||
|
||||
if probe.since_speak >= SPEAK || exiting {
|
||||
probe.since_speak = std::time::Duration::ZERO;
|
||||
let w = std::mem::replace(
|
||||
&mut probe.window,
|
||||
Window {
|
||||
gain: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
speak(w, probe.rate);
|
||||
}
|
||||
if exiting {
|
||||
let dir = probe.dir.clone();
|
||||
let marks = probe.marks;
|
||||
probe.event("end", "");
|
||||
info!(
|
||||
"── sound probe closed ────────────────────────────────────────────\n\
|
||||
{marks} marker(s) stamped. Files in {}\n\
|
||||
Read it back with: scripts/soundprobe.py {}\n\
|
||||
──────────────────────────────────────────────────────────────────",
|
||||
dir.display(),
|
||||
dir.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One window's spoken line — the live half, so the director can see whether the numbers moved at
|
||||
/// the moment they heard it without waiting for the analysis.
|
||||
fn speak(w: Window, rate: u32) {
|
||||
let ms = |samples: u64| samples as f64 / 2.0 / f64::from(rate) * 1000.0;
|
||||
if w.nonfinite > 0 {
|
||||
error!(
|
||||
"sound probe: {} NON-FINITE sample(s) in the mix — that is a defect upstream, not a \
|
||||
loud passage, and it is inaudible to every other meter. This is very likely what you \
|
||||
are hearing.",
|
||||
w.nonfinite
|
||||
);
|
||||
}
|
||||
if w.overruns > 0 {
|
||||
warn!(
|
||||
"sound probe: {} missed mix deadline(s), peak load {:.0}% of budget — an underrun is \
|
||||
audible as a crack and the limiter cannot touch it.",
|
||||
w.overruns,
|
||||
w.load * 100.0
|
||||
);
|
||||
}
|
||||
if w.refused > 0 {
|
||||
warn!(
|
||||
"sound probe: {} sound(s) refused (voice arena full)",
|
||||
w.refused
|
||||
);
|
||||
}
|
||||
if w.over > 0 {
|
||||
warn!(
|
||||
"sound probe: mix asked {:.2}x full scale ({:+.1} dBFS) for ~{:.0} ms; limiter pulled \
|
||||
{:.1} dB; {} voice(s) live.",
|
||||
w.peak,
|
||||
20.0 * w.peak.max(1e-6).log10(),
|
||||
ms(w.over),
|
||||
20.0 * w.gain.max(1e-6).log10(),
|
||||
w.voices,
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"sound probe: mix peak {:.2}, {} voice(s), load {:.0}% — nothing over full scale.",
|
||||
w.peak,
|
||||
w.voices,
|
||||
w.load * 100.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker key (see the module docs). Bound only while probing.
|
||||
pub(super) fn marker(mut out: NonSendMut<SoundOutput>, keys: Res<ButtonInput<KeyCode>>) {
|
||||
if !keys.just_pressed(MARK_KEY) {
|
||||
return;
|
||||
}
|
||||
let Some(probe) = out.probe.as_mut() else {
|
||||
return;
|
||||
};
|
||||
probe.marks += 1;
|
||||
let n = probe.marks;
|
||||
let frame = probe.audio_frame();
|
||||
probe.event("mark", &format!(r#","n":{n}"#));
|
||||
info!(
|
||||
"sound probe: ▶ MARK {n} at {:.2}s into the capture — noted.",
|
||||
frame as f64 / f64::from(probe.rate)
|
||||
);
|
||||
}
|
||||
|
||||
/// Install the probe's systems. Registered unconditionally; every system early-returns in one
|
||||
/// `Option` check when `$WOW_SOUND_PROBE` is unset, which is the normal case.
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_systems(Update, (tick, marker));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A kit name is the one free-form string a row carries; a quote or a backslash in it must not
|
||||
/// produce a line the analyser cannot parse.
|
||||
#[test]
|
||||
fn kit_names_stay_valid_json() {
|
||||
assert_eq!(esc("HolyProtection"), "HolyProtection");
|
||||
assert_eq!(esc(r#"say "hi""#), r#"say \"hi\""#);
|
||||
assert_eq!(esc(r"back\slash"), r"back\\slash");
|
||||
assert_eq!(esc("tab\there"), "tab here");
|
||||
}
|
||||
|
||||
/// `$WOW_SOUND_PROBE` reads as a flag *or* a path, and the off-forms really are off — a probe
|
||||
/// that silently records every session would be a footgun on the director's machine.
|
||||
#[test]
|
||||
fn the_env_var_reads_as_flag_or_path() {
|
||||
for off in ["0", "false", "off", ""] {
|
||||
assert!(matches!(off, "0" | "false" | "off" | ""));
|
||||
}
|
||||
for on in ["1", "true", "yes", "on"] {
|
||||
assert!(matches!(on, "1" | "true" | "yes" | "on"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -729,6 +729,19 @@ fn stop_world_soundscape(zone: &mut ZoneAudio, reason: &str) {
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
/// Report this module's live stream voices into the global budget (decision 1557).
|
||||
///
|
||||
/// Rewritten from the live handles every frame rather than incremented and decremented, so a
|
||||
/// fade that gets interrupted — or a slot replaced mid-crossfade — cannot leave the budget
|
||||
/// believing in a voice that stopped. The outgoing leg of an ambience crossfade is deliberately
|
||||
/// not counted: its handle is released at the swap, so we do not hold it and cannot honestly say
|
||||
/// whether it is still ringing.
|
||||
fn report_stream_voices(zone: NonSend<ZoneAudio>, mut out: NonSendMut<super::SoundOutput>) {
|
||||
let live = |s: kira::sound::PlaybackState| s != kira::sound::PlaybackState::Stopped;
|
||||
out.zone_streams = usize::from(zone.music.as_ref().is_some_and(|h| live(h.state())))
|
||||
+ usize::from(zone.ambience.as_ref().is_some_and(|h| live(h.state())));
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.insert_non_send_resource(ZoneAudio::default())
|
||||
.add_systems(Startup, load_area_sounds.after(AssetSet::Open))
|
||||
@@ -739,6 +752,9 @@ pub(super) fn plugin(app: &mut App) {
|
||||
.run_if(super::world_audio_live)
|
||||
.in_set(WorldStage::Present),
|
||||
)
|
||||
// Unconditional: the budget must fall back to zero when the soundscape stops, and a
|
||||
// system gated on `world_audio_live` would freeze the last count instead.
|
||||
.add_systems(Update, report_stream_voices)
|
||||
.add_systems(
|
||||
OnExit(crate::char_select::ClientState::InWorld),
|
||||
leave_world,
|
||||
|
||||
@@ -226,33 +226,27 @@ pub(crate) fn commit_item_cast_on_pick(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let cat = enchants.as_deref().map(|e| &e.0);
|
||||
let cat = enchants.as_deref();
|
||||
let fields = ladder.items.object(item_guid);
|
||||
let item = ClickedItem {
|
||||
class,
|
||||
subclass,
|
||||
inventory_type,
|
||||
// `0x5da2c0`: already soulbound, or already carrying an enchant that binds.
|
||||
already_bound: fields.is_some_and(|f| {
|
||||
f.item_flags().is_some_and(|flags| flags & 0x1 != 0)
|
||||
|| (0..7).any(|slot| {
|
||||
live_enchant(f, slot, cat).is_some_and(|id| {
|
||||
cat.is_some_and(|c: &benilla_formats::EnchantCatalog| {
|
||||
c.binds_the_item(id)
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
existing_enchant: [0u8, 1].map(|slot| fields.and_then(|f| live_enchant(f, slot, cat))),
|
||||
// `0x5da2c0`: already soulbound, or already carrying an enchant that binds. The
|
||||
// item tooltip's §6 Soulbound override reads the SAME predicate — see
|
||||
// [`crate::items::already_bound`].
|
||||
already_bound: fields.is_some_and(|f| crate::items::already_bound(f, cat)),
|
||||
existing_enchant: [0u8, 1]
|
||||
.map(|slot| fields.and_then(|f| crate::items::live_enchant(f, slot, cat))),
|
||||
};
|
||||
// `495e93` — the enchant this cast would apply, resolved once and read by both confirms.
|
||||
// `EffectMiscValue` is signed and the ref tests `jl` before its range compare.
|
||||
let new = u32::try_from(def.effect_misc_value[0])
|
||||
.ok()
|
||||
.filter(|&id| cat.is_some_and(|c| c.has_row(id)))
|
||||
.filter(|&id| cat.is_some_and(|c| c.0.has_row(id)))
|
||||
.map(|id| NewEnchant {
|
||||
id,
|
||||
binds: cat.is_some_and(|c| c.binds_the_item(id)),
|
||||
binds: crate::items::binds(id, cat),
|
||||
});
|
||||
match item_bind_verdict(def, &item, new, confirmed) {
|
||||
ItemBind::Refuse(reason) => {
|
||||
@@ -270,7 +264,11 @@ pub(crate) fn commit_item_cast_on_pick(
|
||||
ItemBind::ConfirmReplace { existing, new } => {
|
||||
// `4960d0`/`4960d4` read both names off the rows with no suppression gate — the
|
||||
// OLD one first, then the NEW, matching `"Do you want to replace %s with %s?"`.
|
||||
let name = |id: u32| cat.and_then(|c| c.name(id)).unwrap_or_default().to_string();
|
||||
let name = |id: u32| {
|
||||
cat.and_then(|c| c.0.name(id))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
debug!("ui_action: replace-enchant confirm {existing} -> {new} on {item_guid:#x}");
|
||||
parked.0 = Some(item_guid);
|
||||
script.fire_event(
|
||||
@@ -307,18 +305,6 @@ pub(crate) fn commit_item_cast_on_pick(
|
||||
}
|
||||
}
|
||||
|
||||
/// One `ITEM_FIELD_ENCHANTMENT` slot as the confirms read it: the raw id must be **positive** (the
|
||||
/// ref's `jl` skip at `495ef4`/`5da306`) and must name a real `SpellItemEnchantment` row (its
|
||||
/// `testl %eax,%eax` after the table load). Anything else is "no enchant here".
|
||||
fn live_enchant(
|
||||
fields: &benilla_protocol::messages::ObjectFields,
|
||||
slot: u8,
|
||||
cat: Option<&benilla_formats::EnchantCatalog>,
|
||||
) -> Option<u32> {
|
||||
let id = u32::try_from(fields.item_enchant(slot)?).ok()?;
|
||||
cat.is_some_and(|c| c.has_row(id)).then_some(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -573,6 +573,10 @@ fn slot_view(
|
||||
.filter(|&(_, max)| max > 0);
|
||||
// ITEM_FIELD_FLAGS — the broken/alert laws' wrapped (0x08) and force-red (0x10) bits.
|
||||
let flags = obj.item_flags().unwrap_or(0);
|
||||
// `0x5da2c0` — soulbound, or carrying a binding enchant: the equipped tooltip's §6 Soulbound
|
||||
// override (B310 — the doll is exactly where it was reported). Read off the raw descriptor,
|
||||
// never off the enchant LINES below.
|
||||
let already_bound = crate::items::already_bound(obj, rolls.enchants);
|
||||
// ITEM_FIELD_CREATOR → the ask-once name cache — the equipped tooltip's "<Made by %s>"
|
||||
// line (see the bag feed's twin in `ui_items::feed::resolve_slot`).
|
||||
let creator = obj
|
||||
@@ -641,6 +645,7 @@ fn slot_view(
|
||||
link,
|
||||
durability,
|
||||
flags,
|
||||
already_bound,
|
||||
locked: pending.contains(EQUIPMENT_BAG, live_id),
|
||||
equip_slots,
|
||||
bar_placeable,
|
||||
@@ -711,6 +716,9 @@ fn inventory_slots(
|
||||
inv[0] = Some(InvSlotView {
|
||||
durability: None,
|
||||
flags: 0,
|
||||
// Ammo is the one doll slot with no item OBJECT behind it (it is built from the
|
||||
// template alone), so the runtime-bind predicate has nothing to read.
|
||||
already_bound: false,
|
||||
item_id: ammo_id,
|
||||
icon,
|
||||
count: ammo_count(store, items, ammo_id),
|
||||
|
||||
@@ -548,7 +548,7 @@ fn resolve_slot(
|
||||
// (both live on `Items`): one `Option<ms>` per enchant slot.
|
||||
let enchant_ms: [Option<u64>; 7] =
|
||||
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
|
||||
let (entry, count, durability, readable, creator, flags, roll, enchant_lines) =
|
||||
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) =
|
||||
match items.object(guid) {
|
||||
Some(fields) => (
|
||||
fields.object_entry().unwrap_or(0),
|
||||
@@ -572,6 +572,9 @@ fn resolve_slot(
|
||||
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
|
||||
// `ITEM_FIELD_FLAGS` — the tooltip's UNLOCKED (0x4) / WRAPPED (0x8) sub-gates.
|
||||
fields.item_flags().unwrap_or(0),
|
||||
// `0x5da2c0` — soulbound, or carrying a binding enchant: the §6 Soulbound
|
||||
// override (B310). Read off the raw descriptor, not off the enchant LINES below.
|
||||
crate::items::already_bound(fields, rolls.enchants),
|
||||
// `ITEM_FIELD_RANDOM_PROPERTIES_ID` — the roll behind the NAME's "of the Bear"
|
||||
// (decision 1547). Only the name: the roll's own enchants are already in the
|
||||
// instance's slots 2..6 below, written there by the server.
|
||||
@@ -609,6 +612,7 @@ fn resolve_slot(
|
||||
readable,
|
||||
creator,
|
||||
flags,
|
||||
already_bound,
|
||||
enchants: enchant_lines,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -636,6 +640,7 @@ fn resolve_slot(
|
||||
readable,
|
||||
creator,
|
||||
flags,
|
||||
already_bound,
|
||||
enchants: enchant_lines,
|
||||
equip_slots: find_equip_slot(t.inventory_type),
|
||||
bar_placeable: t.placeable_on_action_bar(),
|
||||
|
||||
@@ -742,6 +742,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -429,6 +429,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
|
||||
@@ -508,6 +509,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
||||
@@ -615,6 +617,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
|
||||
@@ -634,6 +637,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
2,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
|
||||
@@ -827,6 +831,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -914,6 +919,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
|
||||
@@ -1043,6 +1049,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -1497,6 +1504,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
|
||||
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
|
||||
let mut inv: benilla_ui::script::InventorySlots = Default::default();
|
||||
inv[20] = Some(benilla_ui::script::InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
|
||||
@@ -82,6 +82,7 @@ fn combat_stats() -> UnitCombatStats {
|
||||
fn inventory_with_head_item() -> InventorySlots {
|
||||
let mut slots: InventorySlots = Default::default();
|
||||
slots[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -106,6 +107,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
benilla_ui::script::ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
|
||||
@@ -310,6 +310,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -32,6 +32,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -25,6 +25,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -227,6 +228,7 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -690,6 +690,7 @@ fn backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -32,6 +32,10 @@ pub(super) struct PointerFeed<'w> {
|
||||
/// TOGGLEUI ([`crate::ui_hide::UiHidden`]): a hidden UI takes no mouse at all — it rides here
|
||||
/// because that is precisely the pointer half of this pass.
|
||||
hidden: Res<'w, crate::ui_hide::UiHidden>,
|
||||
/// A headless probe is driving the pointer itself ([`super::SyntheticPointer`]) — the real
|
||||
/// cursor must not fight it. Same arm as `hidden` below, for the same reason: the mouse half
|
||||
/// of this pass is not ours this frame.
|
||||
synthetic: Res<'w, super::SyntheticPointer>,
|
||||
}
|
||||
|
||||
impl PointerFeed<'_> {
|
||||
@@ -86,6 +90,11 @@ pub(super) fn feed_ui_input(
|
||||
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
|
||||
let world_pick = pointer.world_pick();
|
||||
let ui_hidden = pointer.hidden.0;
|
||||
// A headless probe is driving the pointer itself this frame (the drag probe). It takes the
|
||||
// WHOLE mouse half out — including the else-arm below, which is the half that matters: a
|
||||
// `pointer_left_window` between the synthetic press and the synthetic release would disarm
|
||||
// the gesture the probe is in the middle of.
|
||||
let synthetic = pointer.synthetic.0;
|
||||
let (hover, click_consumed, payload_held) = (
|
||||
&mut pointer.hover,
|
||||
&mut pointer.click_consumed,
|
||||
@@ -120,7 +129,10 @@ pub(super) fn feed_ui_input(
|
||||
// nobody can see must not eat the click or arm a tooltip, and the else-arm below is exactly the
|
||||
// "no pointer here" bookkeeping (leave the hovered frame once, disarm any press/drag every
|
||||
// frame) that keeps a stale gesture from firing when the UI comes back.
|
||||
if let Some(cursor) = window.cursor_position().filter(|_| !ui_hidden) {
|
||||
if let Some(cursor) = window
|
||||
.cursor_position()
|
||||
.filter(|_| !ui_hidden && !synthetic)
|
||||
{
|
||||
// Window cursor is logical px, y-down from top-left; the UI is y-up 768-virtual units
|
||||
// (decisions 0582 + 0584's uiScale dial) — flip through the window height, then ÷s into
|
||||
// the VM's space (the inverse of the extract seam's ×s).
|
||||
@@ -189,7 +201,7 @@ pub(super) fn feed_ui_input(
|
||||
if scroll.delta.y != 0.0 {
|
||||
script.mouse_wheel(x, y, scroll.delta.y);
|
||||
}
|
||||
} else {
|
||||
} else if !synthetic {
|
||||
// The OS pointer left the window: leave whatever frame was hovered (once, on the
|
||||
// Some→None transition) and — every frame it stays outside — clear any armed press/drag
|
||||
// (`UiScript::pointer_left_window`), since no release is ever fed to end it and a stale
|
||||
|
||||
@@ -65,6 +65,19 @@ pub(crate) use manifest::{load_font_registry, load_ingame_ui};
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PointerOverUi(pub(crate) bool);
|
||||
|
||||
/// **A synthetic pointer owns the mouse this frame** — set by the headless drag probe
|
||||
/// ([`crate::capture::ProbeDragPlugin`]) while it drives a gesture through the real pointer path.
|
||||
///
|
||||
/// [`input::feed_ui_input`] skips its whole mouse half while this is set, and that is the ONLY
|
||||
/// thing it does. Without it a scripted gesture cannot exist: the OS cursor is wherever the
|
||||
/// director left it (usually outside a backgrounded probe window), so every frame between the
|
||||
/// synthetic press and the synthetic release would feed the real position — dragging the frame to
|
||||
/// the wrong place at best, and at worst calling `pointer_left_window`, which disarms the very
|
||||
/// gesture the probe just armed. The keyboard half is untouched: a probe driving the mouse has no
|
||||
/// business swallowing keys.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct SyntheticPointer(pub(crate) bool);
|
||||
|
||||
/// The egui dev overlay's half of the pointer arbitration, written each egui pass by the debug
|
||||
/// panel's `track_pointer_over_ui`. **Defined here, with the arbiter that reads it** (decision
|
||||
/// 1174 finishing 0026): the type has to exist in a build with no dev overlays compiled in, and
|
||||
@@ -277,6 +290,7 @@ impl Plugin for UiScriptPlugin {
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<UiCostWanted>()
|
||||
.init_resource::<PointerOverUi>()
|
||||
.init_resource::<SyntheticPointer>()
|
||||
.init_resource::<EguiPointerOver>()
|
||||
.init_resource::<InspectMode>()
|
||||
.init_resource::<PlayerUiHover>()
|
||||
|
||||
@@ -845,3 +845,150 @@ fn the_geometry_matches_both_reference_files() {
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// **A drag the cursor carries off the window edge ENDS — it does not glue the row to the mouse
|
||||
/// for the rest of the session** (B310).
|
||||
///
|
||||
/// The defect this pins is not in this file at all; it is the engine's, and the raid grid is
|
||||
/// simply where it bites hardest. No release is fed once the OS pointer is outside the window, so
|
||||
/// `UiScript::pointer_left_window` used to drop the gesture silently: `OnDragStop` never ran, this
|
||||
/// pane's `MOVING_RAID_MEMBER` stayed set, the engine's one `StartMoving` slot stayed taken, and
|
||||
/// the row followed the cursor around the screen from then on — swallowing every press aimed at
|
||||
/// any row underneath it. "I moved one member and then could not move any other" is what that
|
||||
/// looks like from a hand on the mouse.
|
||||
///
|
||||
/// The three things this asserts are the three the bug broke, in order of how visible they are:
|
||||
/// the row goes home, the pane forgets it, and the next row still drags.
|
||||
#[test]
|
||||
fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_cursor() {
|
||||
let mut s = setup();
|
||||
open_raid_tab(&mut s);
|
||||
push_raid(&mut s, twelve());
|
||||
|
||||
let left_of =
|
||||
|s: &UiScript, f: &str| -> f32 { s.eval(&format!("return {f}:GetLeft()")).unwrap() };
|
||||
let centre = |s: &UiScript, f: &str| -> (f32, f32) {
|
||||
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
|
||||
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
|
||||
let t: f32 = s.eval(&format!("return {f}:GetTop()")).unwrap();
|
||||
let b: f32 = s.eval(&format!("return {f}:GetBottom()")).unwrap();
|
||||
((l + r) / 2.0, (t + b) / 2.0)
|
||||
};
|
||||
|
||||
let home = left_of(&s, "RaidGroupButton7");
|
||||
let (fx, fy) = centre(&s, "RaidGroupButton7");
|
||||
s.mouse_button(fx, fy, "LeftButton", true);
|
||||
s.mouse_move(fx + 40.0, fy + 40.0); // past the threshold ⇒ OnDragStart ⇒ StartMoving
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MOVING_RAID_MEMBER:GetName()")
|
||||
.unwrap(),
|
||||
"RaidGroupButton7",
|
||||
"the drag is in flight"
|
||||
);
|
||||
|
||||
s.pointer_left_window();
|
||||
s.tick(0.016);
|
||||
assert!(
|
||||
s.eval::<bool>("return MOVING_RAID_MEMBER == nil").unwrap(),
|
||||
"the pane's own drag state is cleared by the OnDragStop the abandon fires"
|
||||
);
|
||||
assert_eq!(
|
||||
left_of(&s, "RaidGroupButton7"),
|
||||
home,
|
||||
"the row springs back to its slot — a drop on nothing is not a move"
|
||||
);
|
||||
|
||||
// The row is no longer following anything.
|
||||
s.mouse_move(400.0, 300.0);
|
||||
s.tick(0.016);
|
||||
s.mouse_move(500.0, 200.0);
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
left_of(&s, "RaidGroupButton7"),
|
||||
home,
|
||||
"…and it stays there however far the cursor travels"
|
||||
);
|
||||
|
||||
// And the next row drags normally, which is the thing the director actually lost.
|
||||
let (gx, gy) = centre(&s, "RaidGroupButton8");
|
||||
let (tx, ty) = centre(&s, "RaidGroup5Slot1");
|
||||
s.mouse_button(gx, gy, "LeftButton", true);
|
||||
s.mouse_move(gx + 10.0, gy + 10.0);
|
||||
s.tick(0.016);
|
||||
s.mouse_move(tx, ty);
|
||||
s.tick(0.016);
|
||||
s.mouse_button(tx, ty, "LeftButton", false);
|
||||
assert_eq!(
|
||||
s.take_party_requests(),
|
||||
vec![PartyRequest::SetSubgroup { index: 8, group: 5 }],
|
||||
"the row after the abandoned one still moves"
|
||||
);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"and nothing raised: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole gesture, through the REAL pointer path, three times over — press, cross the
|
||||
/// threshold, travel, release — with the roster echo in between, exactly as `/partytest raid`
|
||||
/// feeds it.
|
||||
///
|
||||
/// Its sibling `dragging_a_row_moves_swaps_or_springs_back` calls `RaidGroupButton_OnDragStop`
|
||||
/// with the globals set by hand, which is the *landing* logic and nothing else: it never fires
|
||||
/// `OnDragStart`, never calls `StartMoving`, never runs the hover sweep, and — the part that
|
||||
/// mattered — never drags a SECOND row. This one does all four.
|
||||
#[test]
|
||||
fn one_drag_does_not_cost_the_next_one() {
|
||||
let mut s = setup();
|
||||
open_raid_tab(&mut s);
|
||||
let mut raid = vec![row("Me", 2, 1, "Warrior", true, false)];
|
||||
for i in 1..25u32 {
|
||||
raid.push(row(
|
||||
&format!("Member{i}"),
|
||||
0,
|
||||
i / 5 + 1,
|
||||
"Priest",
|
||||
true,
|
||||
false,
|
||||
));
|
||||
}
|
||||
push_raid(&mut s, raid.clone());
|
||||
|
||||
let centre = |s: &UiScript, f: &str| -> (f32, f32) {
|
||||
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
|
||||
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
|
||||
let t: f32 = s.eval(&format!("return {f}:GetTop()")).unwrap();
|
||||
let b: f32 = s.eval(&format!("return {f}:GetBottom()")).unwrap();
|
||||
((l + r) / 2.0, (t + b) / 2.0)
|
||||
};
|
||||
|
||||
for (row_index, group) in [(7u32, 6u32), (8, 6), (9, 7)] {
|
||||
let from = format!("RaidGroupButton{row_index}");
|
||||
let seat = if group == 6 && row_index == 8 { 2 } else { 1 };
|
||||
let to = format!("RaidGroup{group}Slot{seat}");
|
||||
let (fx, fy) = centre(&s, &from);
|
||||
let (tx, ty) = centre(&s, &to);
|
||||
s.mouse_button(fx, fy, "LeftButton", true);
|
||||
s.mouse_move(fx + 10.0, fy + 10.0);
|
||||
s.tick(0.016);
|
||||
s.mouse_move(tx, ty);
|
||||
s.tick(0.016);
|
||||
s.mouse_button(tx, ty, "LeftButton", false);
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.take_party_requests(),
|
||||
vec![PartyRequest::SetSubgroup {
|
||||
index: row_index,
|
||||
group
|
||||
}],
|
||||
"drag {row_index} onto group {group}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{from}: {:?}", s.errors());
|
||||
// The sandbox echo `/partytest raid` supplies, so the next drag starts from a repainted
|
||||
// grid rather than a frozen one.
|
||||
raid[row_index as usize - 1].subgroup = group - 1;
|
||||
push_raid(&mut s, raid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,212 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every archive path a shipped LUA chunk names survives its own escaping** — the mirror image
|
||||
/// of the doubled-separator check above, and the tripwire for the defect that left the raid tab
|
||||
/// with no window art at all (the `FriendsFrame_Update` arm was written
|
||||
/// `"Interface\PaperDollInfoFrame\…"`, one backslash, so Lua ate both separators and
|
||||
/// `SetTexture` was handed `InterfacePaperDollInfoFrameUI-Character-General-TopLeft`).
|
||||
///
|
||||
/// The two halves of the same trap, and they pull in OPPOSITE directions, which is exactly why
|
||||
/// neither check can stand in for the other:
|
||||
///
|
||||
/// - an **XML attribute** is not a Lua string, so `file="Interface\Buttons\X"` is right and a
|
||||
/// doubled separator there is the bug (the sweep above);
|
||||
/// - a **Lua literal** is, so `SetTexture("Interface\\Buttons\\X")` is right and a single
|
||||
/// separator here is the bug — and it fails SILENTLY: `\P` is not an escape Lua rejects, it is
|
||||
/// one Lua drops the backslash from, so the string is well-formed, the call succeeds, and the
|
||||
/// only symptom is a texture that never appears.
|
||||
///
|
||||
/// The shape half runs without client data; the resolution half needs the archives and skips
|
||||
/// without them, the same posture as its sibling.
|
||||
#[test]
|
||||
fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
|
||||
use benilla_ui::framexml::{Element, ScriptRef, TopLevel};
|
||||
|
||||
// The archive roots a path literal can start with. A prefix list rather than "has a
|
||||
// backslash", because Lua strings legitimately carry `\n`/`\124` and those are not paths.
|
||||
const ROOTS: [&str; 7] = [
|
||||
"interface",
|
||||
"textures",
|
||||
"world",
|
||||
"sound",
|
||||
"character",
|
||||
"item",
|
||||
"spells",
|
||||
];
|
||||
|
||||
/// Every `"…"`/`'…'` literal in one Lua chunk, as its RAW source text (escapes uninterpreted —
|
||||
/// what this sweep is looking for is an escape Lua would have eaten, so interpreting them
|
||||
/// first would destroy the evidence).
|
||||
///
|
||||
/// **Comments and long strings are skipped, not scanned.** A `--` line quoting a path (this
|
||||
/// house writes plenty: "the file is `Interface\\Foo\\Bar`") is prose, and a prose backslash
|
||||
/// is nobody's bug — flagging one would train the next session to weaken this test rather
|
||||
/// than read it.
|
||||
fn literals(chunk: &str) -> Vec<(String, bool)> {
|
||||
let src: Vec<char> = chunk.chars().collect();
|
||||
let at = |i: usize, s: &str| src[i..].starts_with(&s.chars().collect::<Vec<_>>()[..]);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
if at(i, "--") {
|
||||
i = if at(i + 2, "[[") {
|
||||
src[i..]
|
||||
.windows(2)
|
||||
.position(|w| w == [']', ']'])
|
||||
.map_or(src.len(), |k| i + k + 2)
|
||||
} else {
|
||||
src[i..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map_or(src.len(), |k| i + k + 1)
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if at(i, "[[") {
|
||||
i = src[i + 2..]
|
||||
.windows(2)
|
||||
.position(|w| w == [']', ']'])
|
||||
.map_or(src.len(), |k| i + 2 + k + 2);
|
||||
continue;
|
||||
}
|
||||
let quote = src[i];
|
||||
if quote != '"' && quote != '\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (mut raw, mut j, mut closed) = (String::new(), i + 1, false);
|
||||
while j < src.len() {
|
||||
let c = src[j];
|
||||
if c == '\\' && j + 1 < src.len() {
|
||||
raw.push(c);
|
||||
raw.push(src[j + 1]);
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
// A newline before the closing quote means this was never a literal (an
|
||||
// apostrophe in a comment, most often) — drop it and resume at the next char.
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
if c == quote {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
raw.push(c);
|
||||
j += 1;
|
||||
}
|
||||
if closed {
|
||||
// Is this the WHOLE path, or a fragment? A literal with `..` against either side
|
||||
// is being concatenated onto, and one carrying a `%` spec is a `format` template —
|
||||
// in both cases the string in the source is a prefix and resolving it would fail
|
||||
// by construction. Read off the SITE rather than guessed from the text (a
|
||||
// "ends with a dash" rule would be a list to feed forever), so a fragment that
|
||||
// later becomes whole starts being resolved on its own.
|
||||
let before = src[..i].iter().rposition(|c| !c.is_whitespace());
|
||||
let after = src[j + 1..].iter().position(|c| !c.is_whitespace());
|
||||
let joined = before.is_some_and(|k| k >= 1 && src[k] == '.' && src[k - 1] == '.')
|
||||
|| after.is_some_and(|k| {
|
||||
src.get(j + 1 + k) == Some(&'.') && src.get(j + 2 + k) == Some(&'.')
|
||||
});
|
||||
// …and the two fragments a `..` cannot see, because they are assigned to a
|
||||
// variable first and joined somewhere else: a literal ending in the SEPARATOR is a
|
||||
// directory, and one ending in a DASH is a family prefix (`"…\\MageFire-"` .. rank).
|
||||
// Neither is a file name — no texture in the chain ends in either character — so
|
||||
// this excludes fragments without excluding anything real.
|
||||
let fragment = raw.ends_with('\\') || raw.ends_with('-');
|
||||
let whole = !joined && !fragment && !raw.contains('%');
|
||||
out.push((raw, whole));
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Every Lua chunk in the file: the top-level `<Script>` blocks and every element body (the
|
||||
// `<OnLoad>`-family handlers). Through the PARSER, not a text scan — an attribute value is
|
||||
// also inside double quotes, and a text scan cannot tell the two apart, which is the whole
|
||||
// distinction this test exists to make.
|
||||
fn bodies(el: &Element, out: &mut Vec<String>) {
|
||||
if !el.body.trim().is_empty() {
|
||||
out.push(el.body.clone());
|
||||
}
|
||||
for child in &el.children {
|
||||
bodies(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||||
let mut paths: Vec<(String, String, bool)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|e| e != "xml") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
let file = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let mut chunks = Vec::new();
|
||||
for item in &benilla_ui::framexml::parse(&text).expect("parses").items {
|
||||
match item {
|
||||
TopLevel::Script(ScriptRef::Inline { body, .. }) => chunks.push(body.clone()),
|
||||
TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
|
||||
bodies(el, &mut chunks);
|
||||
}
|
||||
TopLevel::Script(ScriptRef::File(_)) | TopLevel::Include(_) => {}
|
||||
}
|
||||
}
|
||||
for (raw, whole) in chunks.iter().flat_map(|c| literals(c)) {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
if ROOTS.iter().any(|r| lower.starts_with(r)) && raw.contains('\\') {
|
||||
paths.push((file.to_string(), raw, whole));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never let the sweep pass by matching nothing.
|
||||
assert!(
|
||||
paths.len() >= 20,
|
||||
"only {} archive paths swept out of the shipped Lua",
|
||||
paths.len()
|
||||
);
|
||||
|
||||
// The shape half: after collapsing every `\\` pair, no backslash may remain — one that does
|
||||
// is a separator Lua is about to eat.
|
||||
for (file, raw, _) in &paths {
|
||||
assert!(
|
||||
!raw.replace("\\\\", "").contains('\\'),
|
||||
"{file}: the Lua literal \"{raw}\" has SINGLE separators — Lua drops the backslash \
|
||||
from every one of them, so the path arrives with its folders run together, the \
|
||||
archive lookup misses, and the texture silently never appears. Double them."
|
||||
);
|
||||
}
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let missing: Vec<String> = paths
|
||||
.iter()
|
||||
// `sprite_candidates` answers for TEXTURES; a sound or a model path is a different
|
||||
// resolver's business, so only the two texture roots take the resolution half — and only
|
||||
// a WHOLE path, never a concatenation fragment.
|
||||
.filter(|(_, raw, whole)| {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
*whole && (lower.starts_with("interface") || lower.starts_with("textures"))
|
||||
})
|
||||
.filter(|(_, raw, _)| {
|
||||
let real = raw.replace("\\\\", "\\");
|
||||
!benilla_assets::sprite_candidates(&real)
|
||||
.iter()
|
||||
.any(|c| chain.contains(c))
|
||||
})
|
||||
.map(|(file, raw, _)| format!("{file}: \"{raw}\""))
|
||||
.collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"archive paths a shipped Lua chunk names that resolve to nothing: {missing:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every `text=` in the shipped UI is answerable against the REAL `GlobalStrings.lua`** — the
|
||||
/// tripwire for the defect that put "CREATE_MACROS" across the macro window's title bar (0991).
|
||||
///
|
||||
|
||||
@@ -54,6 +54,7 @@ fn harness() -> UiScript {
|
||||
fn seed_items(s: &mut UiScript) {
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
inv[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -98,6 +99,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
@@ -255,6 +257,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
|
||||
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
inv[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: Some((0, 40)),
|
||||
flags: 0,
|
||||
|
||||
@@ -416,6 +416,10 @@ mod markup_tests {
|
||||
// The three the kernel counts differently from a split.
|
||||
assert_eq!(n("\n"), 1, "a <BR/> block is ONE blank line");
|
||||
assert_eq!(n("a\n"), 1);
|
||||
// The item tooltip's SET spacer, the reference's own literal `0x854b2c`: ONE row
|
||||
// carrying one space. Two under a split, and then the blank gold lines above the set
|
||||
// bonuses would each be a row taller than 1.12's.
|
||||
assert_eq!(n(" \n"), 1, "the §22 blank gold spacer is one row");
|
||||
assert_eq!(n(""), 0, "an empty string is zero lines, height 0.0");
|
||||
// …and everything else is unchanged.
|
||||
assert_eq!(n("a"), 1);
|
||||
|
||||
@@ -232,6 +232,11 @@ pub struct InvSlotView {
|
||||
/// inventory-alert-law `0x4c7ee0`): `0x08` wrapped (a gift — never alerts, never broken),
|
||||
/// `0x10` force-red (alert status 4 regardless of durability).
|
||||
pub flags: u32,
|
||||
/// `0x5da2c0` — **the instance is runtime-bound**: `ITEM_FIELD_FLAGS & 1` (soulbound), or a
|
||||
/// live enchant slot naming a `SpellItemEnchantment` row that binds. App-resolved off the raw
|
||||
/// descriptor (the doll twin of [`super::container::ContainerSlot::already_bound`]); the
|
||||
/// tooltip's §6 bind line overrides to **Soulbound** on it (B310).
|
||||
pub already_bound: bool,
|
||||
/// An `|Hitem:…|h[Name]|h` link once the name is known — the doll twin of
|
||||
/// `ContainerSlot::link`; carried onto the cursor payload so a world-drop `DELETE_ITEM_CONFIRM`
|
||||
/// off an equipped item can report its name (decision 0208 phase 1b).
|
||||
|
||||
@@ -66,6 +66,13 @@ pub struct ContainerSlot {
|
||||
/// UNLOCKED `0x4`, the wrapped-gift arm WRAPPED `0x8` (see
|
||||
/// [`super::char_stats::InvSlotView::flags`], the doll twin).
|
||||
pub flags: u32,
|
||||
/// `0x5da2c0` — **the instance is runtime-bound**: `ITEM_FIELD_FLAGS & 1` (soulbound), or a
|
||||
/// live enchant slot naming a `SpellItemEnchantment` row that binds. App-resolved off the raw
|
||||
/// descriptor ([`crate::items::already_bound`] in benilla-app — the same predicate the enchant
|
||||
/// cursor's bind question asks), because the binding half needs a DBC join and the engine
|
||||
/// holds no item knowledge of its own. The tooltip's §6 bind line overrides to **Soulbound**
|
||||
/// on it (B310); `false` on any source with no streamed item object.
|
||||
pub already_bound: bool,
|
||||
/// The instance's enchant slots, resolved by the app ([`super::EnchantView`]) and in
|
||||
/// enchant-slot order: it joins the id through `SpellItemEnchantment.dbc`'s name column and
|
||||
/// hands over the row's name plus the three facts the line law needs to place and paint it.
|
||||
@@ -708,6 +715,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -872,6 +880,7 @@ mod tests {
|
||||
state.slots.insert(
|
||||
5,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
|
||||
|
||||
@@ -21,7 +21,9 @@ mod drag;
|
||||
mod pet;
|
||||
|
||||
pub(crate) use bar::place_action;
|
||||
pub(crate) use drag::{arm_drag, maybe_start_drag, take_drag, DragGesture, DragRelease};
|
||||
pub(crate) use drag::{
|
||||
abandon_drag, arm_drag, maybe_start_drag, take_drag, DragGesture, DragRelease,
|
||||
};
|
||||
|
||||
/// The sentinel bag id that folds the player's EQUIPPED slots into the ONE payload space
|
||||
/// (decision 0216 §1, extended to the paper doll by decision 0208 phase 1b): a
|
||||
@@ -620,6 +622,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
crate::script::container::ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -256,6 +256,7 @@ mod tests {
|
||||
fn doll_slots() -> crate::script::InventorySlots {
|
||||
let mut slots: crate::script::InventorySlots = Default::default();
|
||||
slots[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -271,6 +272,7 @@ mod tests {
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots[11] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -286,6 +288,7 @@ mod tests {
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots[19] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -308,6 +311,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
@@ -449,6 +453,7 @@ mod tests {
|
||||
});
|
||||
// An equipped bag in Bag0Slot (id 20): its equip_slots is the four bag slots.
|
||||
slots[20] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -486,6 +491,7 @@ mod tests {
|
||||
slots.insert(
|
||||
3,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
||||
|
||||
@@ -35,6 +35,9 @@ pub(crate) struct DragRelease {
|
||||
/// press hit nothing); a press always REPLACES any leftover in-flight gesture — its matching
|
||||
/// release should already have cleared it, but a press is the one moment we know for certain no
|
||||
/// gesture should still be armed against a *different* earlier press.
|
||||
///
|
||||
/// **The replaced gesture is abandoned, not forgotten**: the caller runs [`abandon_drag`] first
|
||||
/// and fires `OnDragStop` for a started one. See that function for why a silent drop is the bug.
|
||||
pub(crate) fn arm_drag(model: &mut Model, hit: Option<FrameHandle>, button: &str, pos: (f32, f32)) {
|
||||
model.drag = hit
|
||||
.filter(|&h| {
|
||||
@@ -77,6 +80,24 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3
|
||||
Some((id, button))
|
||||
}
|
||||
|
||||
/// **Abandon** an in-flight gesture that will never see its release, and report whether anything
|
||||
/// has to be told. Returns the source of a gesture that had **started**, so the caller fires the
|
||||
/// same `OnDragStop` a real release would; an armed-but-unstarted press answers `None`, exactly as
|
||||
/// releasing one does.
|
||||
///
|
||||
/// Two callers, and both are situations the reference simply cannot be in: the OS captures the
|
||||
/// pointer for the whole of a button-held drag there, so the release always arrives. Ours does
|
||||
/// not — the cursor can walk out of the window, and a second button can be pressed mid-gesture —
|
||||
/// and dropping the gesture *silently* in either case is what leaves the UI stuck rather than
|
||||
/// merely cancelled: the addon's `OnDragStop → StopMovingOrSizing` never runs, so the engine's
|
||||
/// single [`Model::moving`] slot stays taken, [`super::super::object::movable::advance_move`]
|
||||
/// keeps gluing that frame to the cursor for the rest of the session, and it swallows every press
|
||||
/// aimed at anything underneath. (B310 — the raid grid, where one drag off the window edge cost
|
||||
/// every later drag.)
|
||||
pub(crate) fn abandon_drag(model: &mut Model) -> Option<FrameHandle> {
|
||||
model.drag.take().filter(|g| g.started).map(|g| g.source)
|
||||
}
|
||||
|
||||
/// Resolve (and clear) the in-flight drag gesture matching a mouse-button release's `button`, if
|
||||
/// any (case-insensitive, the `RegisterForClicks` precedent).
|
||||
pub(crate) fn take_drag(model: &mut Model, button: &str) -> Option<DragRelease> {
|
||||
@@ -423,4 +444,98 @@ mod tests {
|
||||
);
|
||||
assert!(s.cursor_payload().is_none(), "cleared silently");
|
||||
}
|
||||
|
||||
/// **A drag the pointer carries out of the window ENDS, and the frame it was moving stops
|
||||
/// following the cursor** (B310).
|
||||
///
|
||||
/// The reference cannot reach this state — the OS holds the pointer for a button-held drag, so
|
||||
/// the release always arrives — which is exactly why nothing in FrameXML defends against it and
|
||||
/// why the engine has to. Abandoning the gesture *silently* (what this used to do) skips the
|
||||
/// canonical `OnDragStop → StopMovingOrSizing`, so the single move slot stays taken and
|
||||
/// `advance_move` glues the frame to the cursor for the rest of the session, on top of whatever
|
||||
/// the player is trying to click next.
|
||||
#[test]
|
||||
fn a_gesture_the_pointer_carries_out_of_the_window_still_fires_its_stop() {
|
||||
let mut s = drag_script();
|
||||
s.run(
|
||||
r#"
|
||||
stops = 0
|
||||
local a = CreateFrame("Frame", "A")
|
||||
a:SetPoint("BOTTOMLEFT", 100, 100); a:SetSize(200, 100)
|
||||
a:EnableMouse(true); a:SetMovable(true)
|
||||
a:RegisterForDrag("LeftButton")
|
||||
a:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||
a:SetScript("OnDragStop", function() stops = stops + 1; this:StopMovingOrSizing() end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||||
s.mouse_move(200.0, 200.0); // past the threshold ⇒ OnDragStart ⇒ StartMoving
|
||||
// `StartMoving` samples the cursor where it is CALLED, so the starting move moves nothing
|
||||
// (`tests::movable`'s own note) — this is the one that carries the frame.
|
||||
s.mouse_move(250.0, 250.0);
|
||||
s.resolve();
|
||||
let carried: f32 = s.eval("return A:GetLeft()").unwrap();
|
||||
assert_eq!(carried, 150.0, "the frame followed the cursor's 50px");
|
||||
|
||||
s.pointer_left_window();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return stops").unwrap(),
|
||||
1,
|
||||
"the abandon fires the same OnDragStop a release would"
|
||||
);
|
||||
|
||||
// …and the move slot is free, so nothing follows the cursor any more.
|
||||
s.mouse_move(600.0, 500.0);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return A:GetLeft()").unwrap(),
|
||||
carried,
|
||||
"the frame stayed where the abandoned drag left it"
|
||||
);
|
||||
// Fires once, not once per frame the pointer stays outside.
|
||||
s.pointer_left_window();
|
||||
s.pointer_left_window();
|
||||
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
|
||||
}
|
||||
|
||||
/// The same abandon, by the other door: a fresh press REPLACES an in-flight gesture, and a
|
||||
/// started one has to be told. Pressing a second button mid-drag is an ordinary slip of the
|
||||
/// hand, and it used to leave the same stuck move slot.
|
||||
#[test]
|
||||
fn a_press_that_replaces_an_in_flight_gesture_stops_it_first() {
|
||||
let mut s = drag_script();
|
||||
s.run(
|
||||
r#"
|
||||
stops, starts = 0, 0
|
||||
local a = CreateFrame("Frame", "A")
|
||||
a:SetPoint("BOTTOMLEFT", 100, 100); a:SetSize(200, 100)
|
||||
a:EnableMouse(true); a:SetMovable(true)
|
||||
a:RegisterForDrag("LeftButton")
|
||||
a:SetScript("OnDragStart", function() starts = starts + 1; this:StartMoving() end)
|
||||
a:SetScript("OnDragStop", function() stops = stops + 1; this:StopMovingOrSizing() end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||||
s.mouse_move(200.0, 200.0);
|
||||
assert_eq!(s.eval::<i64>("return starts").unwrap(), 1);
|
||||
|
||||
s.mouse_button(200.0, 200.0, "RightButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return stops").unwrap(),
|
||||
1,
|
||||
"the left drag the right press displaced was stopped, not forgotten"
|
||||
);
|
||||
|
||||
// An armed-but-UNSTARTED press fires nothing when it is replaced — the same silence a
|
||||
// release of one gets.
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||||
s.mouse_button(150.0, 150.0, "MiddleButton", true);
|
||||
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1001,9 +1001,30 @@ impl UiScript {
|
||||
let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0);
|
||||
// A FontString with no explicit height takes its host-measured wrapped size
|
||||
// (the measure round-trip — the client's layout↔font-engine seam).
|
||||
//
|
||||
// The stored measure is honoured WITHOUT a key check on purpose
|
||||
// ([`RegionData::measure_key`]): it is the LAST-KNOWN box, held so a line whose
|
||||
// text just changed does not collapse for the one frame the re-measure is in
|
||||
// flight. **Empty text is the case where that promise cannot be kept** — nothing
|
||||
// ever measures an empty string (`fontstrings_needing_measure` and
|
||||
// `stale_measure_key` both filter it out), so a cell that goes from text to `""`
|
||||
// would hold its old box FOREVER. There is no round-trip to wait for: an empty
|
||||
// string's extent is known, and it is zero.
|
||||
//
|
||||
// That forever-stale box is B309. Tooltip line cells are POOLED and reused across
|
||||
// hovers (`clear_content` keeps `measured` deliberately, for the hover re-enter
|
||||
// loop), so the item tooltip's two blank gold SET spacers (`render.rs` §22) landed
|
||||
// on cells that had carried real text on an earlier hover: each drew a full row
|
||||
// out of the dead measure while the plate counted it as zero
|
||||
// (`tooltip::cell` calls empty text `(0,0)`), and the set bonuses hung two rows
|
||||
// below the backdrop. The Lua-visible getters never disagreed — they key-check
|
||||
// (`region::measured_wh`) — which is why only the drawn geometry was wrong.
|
||||
let measured = data
|
||||
.measured
|
||||
.filter(|_| data.text.as_deref().is_some_and(|t| !t.is_empty()));
|
||||
let mut height = data.size.map_or(0.0, |s| s.1);
|
||||
let mut width = data.size.map_or(0.0, |s| s.0);
|
||||
if let Some(m) = &data.measured {
|
||||
if let Some(m) = &measured {
|
||||
if height == 0.0 {
|
||||
height = m.h;
|
||||
}
|
||||
|
||||
@@ -1045,8 +1045,24 @@ impl UiScript {
|
||||
/// `OnClick` if the pointer re-enters and releases over the very frame it left from. The app
|
||||
/// calls this from the same branch that fires the synthetic `OnLeave` (`ui_script/input.rs`).
|
||||
pub fn pointer_left_window(&mut self) {
|
||||
// **A started drag ENDS here, it is not merely forgotten.** The reference cannot reach
|
||||
// this state at all — the OS holds the pointer for the whole of a button-held drag, so its
|
||||
// release always arrives and `OnDragStop` always runs. Ours has no such capture: walk the
|
||||
// cursor off the window edge mid-drag and no release is ever fed. Dropping the gesture
|
||||
// silently (what this did) leaves the addon's `OnDragStop → StopMovingOrSizing` unrun, so
|
||||
// the engine's single `Model::moving` slot stays taken and `object::movable::advance_move`
|
||||
// glues that frame to the cursor for the rest of the session — where it also swallows
|
||||
// every press aimed at whatever is underneath. That is B310: one raid row dragged off the
|
||||
// window edge, and no row could be dragged again. Firing the stop is the faithful end,
|
||||
// because it is the same end the reference's own release gives the handler.
|
||||
let abandoned = {
|
||||
let mut model = self.model_mut();
|
||||
cursor::abandon_drag(&mut model)
|
||||
};
|
||||
if let Some(source) = abandoned {
|
||||
self.fire_drag_stop(source);
|
||||
}
|
||||
let mut model = self.model_mut();
|
||||
model.drag = None;
|
||||
model.mouse_down_on.clear();
|
||||
// [`Model::last_click`] is deliberately NOT cleared here. It looks like it belongs in this
|
||||
// list, and the binary says otherwise: `[CButton+0x334]` has exactly three writers
|
||||
|
||||
@@ -368,12 +368,13 @@ impl UiScript {
|
||||
// "<Button>ButtonDown"; a release fires it when press+release landed on the same frame AND
|
||||
// it registered "<Button>ButtonUp" — UNLESS a started drag is being resolved instead.
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (click_id, drag_release, world_dropped, slider_jump, double_id): (
|
||||
let (click_id, drag_release, world_dropped, slider_jump, double_id, abandoned): (
|
||||
Option<u32>,
|
||||
Option<cursor::DragRelease>,
|
||||
bool,
|
||||
Option<(u32, f32)>,
|
||||
Option<u32>,
|
||||
Option<crate::widget::FrameHandle>,
|
||||
) = {
|
||||
let mut model = self.model_mut();
|
||||
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
|
||||
@@ -386,6 +387,10 @@ impl UiScript {
|
||||
model.mouse_down_on.remove(button);
|
||||
}
|
||||
}
|
||||
// A press REPLACES any in-flight gesture, so a started one has to be ended first
|
||||
// — `abandon_drag`'s own doc carries why a silent drop is a stuck UI rather than a
|
||||
// cancelled drag. Its `OnDragStop` fires below, outside this borrow.
|
||||
let abandoned = cursor::abandon_drag(&mut model);
|
||||
cursor::arm_drag(&mut model, hit_handle, button, (x, y));
|
||||
// A left press on a Slider begins the engine drag (0250 §5): a thumb press grabs
|
||||
// it in place; a track press seats the thumb under the cursor first (the returned
|
||||
@@ -400,7 +405,7 @@ impl UiScript {
|
||||
let click = hit_handle
|
||||
.filter(|&h| button::wants_click(&model, h, &wants))
|
||||
.and(hit_id);
|
||||
(click, None, false, jump, None)
|
||||
(click, None, false, jump, None, abandoned)
|
||||
} else {
|
||||
let pressed = model.mouse_down_on.remove(button);
|
||||
// A left release ends any in-flight thumb drag (decision 0250 §5).
|
||||
@@ -473,7 +478,7 @@ impl UiScript {
|
||||
let double_id = double.and_then(|h| model.frame_to_id.get(&h).copied());
|
||||
// Exclusive, not additive — the double leg is taken *instead of* the single one.
|
||||
let click = if double_id.is_some() { None } else { click };
|
||||
(click, release, dropped, None, double_id)
|
||||
(click, release, dropped, None, double_id, None)
|
||||
}
|
||||
};
|
||||
// A track press's value jump fires OnValueChanged first (outside the borrow), the same
|
||||
@@ -545,24 +550,16 @@ impl UiScript {
|
||||
editbox::drag_end(&self.lua);
|
||||
}
|
||||
}
|
||||
// A gesture this press REPLACED gets its `OnDragStop` too, before anything the new press
|
||||
// does: the handler on the other end has a `StopMovingOrSizing` in it, and until that runs
|
||||
// the engine's one move slot is still held (see `cursor::abandon_drag`).
|
||||
if let Some(source) = abandoned {
|
||||
self.fire_drag_stop(source);
|
||||
}
|
||||
// The drag trio's stop/receive pair, fired outside the model borrow above (only for a
|
||||
// gesture that actually started — see `cursor::DragRelease::started`).
|
||||
if let Some(release) = drag_release.filter(|r| r.started) {
|
||||
let source_id = {
|
||||
let model = self.model_ref();
|
||||
model
|
||||
.arena
|
||||
.frame(release.source)
|
||||
.is_some()
|
||||
.then(|| model.frame_to_id.get(&release.source).copied())
|
||||
.flatten()
|
||||
};
|
||||
if let Some(id) = source_id {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDragStop", Vec::new())
|
||||
{
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
self.fire_drag_stop(release.source);
|
||||
if let Some(id) = hit_id {
|
||||
if let Err(e) =
|
||||
event::fire_widget_handler(&self.lua, id, "OnReceiveDrag", Vec::new())
|
||||
@@ -592,6 +589,26 @@ impl UiScript {
|
||||
hit_id.is_some() || world_dropped
|
||||
}
|
||||
|
||||
/// Fire `OnDragStop` on a gesture's source — the ONE place that ends a started drag, so the
|
||||
/// release path and the two abandon paths ([`cursor::abandon_drag`]) cannot drift apart.
|
||||
/// A source that died between the press and here fires nothing.
|
||||
pub(super) fn fire_drag_stop(&mut self, source: crate::widget::FrameHandle) {
|
||||
let id = {
|
||||
let model = self.model_ref();
|
||||
model
|
||||
.arena
|
||||
.frame(source)
|
||||
.is_some()
|
||||
.then(|| model.frame_to_id.get(&source).copied())
|
||||
.flatten()
|
||||
};
|
||||
if let Some(id) = id {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDragStop", Vec::new()) {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mouse-wheel spin at `(x, y)`: fires `OnMouseWheel(self, delta)` on the hit frame — or,
|
||||
/// when the hit frame carries no OnMouseWheel handler, on its NEAREST ANCESTOR that does (the
|
||||
/// client's bubbling: a reward-row Button inside a ScrollFrame doesn't eat the wheel, the
|
||||
|
||||
@@ -508,3 +508,76 @@ fn the_duration_ladder_ceils_every_arm_but_seconds() {
|
||||
Some("<5 s>"),
|
||||
);
|
||||
}
|
||||
|
||||
/// **B309 — a POOLED line cell that goes empty must not keep the box it measured last hover.**
|
||||
///
|
||||
/// `measured` is a cache the solver honours without a key check on purpose: it is the last-known
|
||||
/// box, held so a line whose text just changed does not collapse for the frame its re-measure is
|
||||
/// in flight. Empty text is the one case where that measure never comes — both measure asks
|
||||
/// filter empty strings out — so before the fix an emptied cell held its dead box FOREVER, and
|
||||
/// only the DRAWN geometry was wrong: the plate's own sum calls empty text zero
|
||||
/// (`tooltip::cell`) and the Lua getters key-check (`region::measured_wh`), so both said 0 while
|
||||
/// the solver stood a full row there.
|
||||
///
|
||||
/// Live, that is the item tooltip's SET block: `ClearLines` keeps the cache (the hover re-enter
|
||||
/// loop depends on it), so the two blank gold spacers land on cells that carried real text on an
|
||||
/// earlier hover. Each drew an uncounted row and the set bonuses hung below the backdrop —
|
||||
/// Frostshake's Field Marshal's Raiment shot, two blanks, two rows.
|
||||
#[test]
|
||||
fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_chain() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// Hover one: line 2 carries real text, and gets measured.
|
||||
s.run(
|
||||
r#"
|
||||
local owner = CreateFrame("Button", "SlotR")
|
||||
owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40)
|
||||
local tt = CreateFrame("GameTooltip", "TTR")
|
||||
tt:SetOwner(owner, "ANCHOR_RIGHT")
|
||||
tt:AddLine("Marshal McBride")
|
||||
tt:AddLine("Level 20")
|
||||
tt:AddLine("PvP")
|
||||
tt:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let sizes = &[
|
||||
("Marshal McBride", 90.0, 14.0),
|
||||
("Level 20", 50.0, 12.0),
|
||||
("PvP", 24.0, 12.0),
|
||||
];
|
||||
measure_all(&mut s, sizes);
|
||||
// Hover two, SAME pooled cells: line 2 is now the blank spacer. Nothing new to measure —
|
||||
// lines 1 and 3 re-validate against their own keys, and an empty string is never asked for.
|
||||
s.run(
|
||||
r#"
|
||||
TTR:ClearLines()
|
||||
TTR:AddLine("Marshal McBride")
|
||||
-- WRAPPED, as the §22 spacer is (`render.rs`'s `addw`): the wrap pin writes a non-zero
|
||||
-- width into `size`, so only the HEIGHT falls through to the measure cache.
|
||||
TTR:AddLine("", 1, 0.82, 0, true)
|
||||
TTR:AddLine("PvP")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
measure_all(&mut s, sizes);
|
||||
s.run(
|
||||
r#"
|
||||
-- Rows 14 + 0 + 12 with two slot gaps ⇒ totalh 30, height 50: the blank costs its gap
|
||||
-- and nothing else, exactly as it does on a cell that never held text.
|
||||
assert(TTR:GetHeight() == 50, "auto height, got " .. tostring(TTR:GetHeight()))
|
||||
-- The one that was wrong: the tail line sits a full pad above the plate's bottom edge.
|
||||
-- Pre-fix it sat 12 BELOW it — line 2's dead "Level 20" box, drawn but never counted.
|
||||
assert(TTRTextLeft3:GetBottom() == TTR:GetBottom() + 10,
|
||||
"tail line inside the plate, got " .. tostring(TTRTextLeft3:GetBottom())
|
||||
.. " vs plate bottom " .. tostring(TTR:GetBottom()))
|
||||
-- And the chain is contiguous through the zero row, as on a cold cell.
|
||||
assert(TTRTextLeft3:GetTop() == TTRTextLeft1:GetBottom() - 4,
|
||||
"chain contiguous through the emptied row, got " .. tostring(TTRTextLeft3:GetTop()))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: None,
|
||||
|
||||
@@ -553,3 +553,91 @@ fn instance_tail_creator_and_readable() {
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **§6's Soulbound override** — the bind line reads the INSTANCE, not only the template
|
||||
/// (B310, Frostshake: an equipped Maiden's Circle still said *Binds when equipped*, and B309's
|
||||
/// own shot showed *Binds when picked up* on the equipped pants).
|
||||
///
|
||||
/// The law (wow-re `tooltip-content-law.md` §6, byte-verified): Bonding `[record+0x194]` ∈ {1..5}
|
||||
/// decides whether a line prints at all; a **runtime-bound instance** (`0x5da2c0` — soulbound
|
||||
/// flag, or a live enchant slot that binds) overrides it to `ITEM_SOULBOUND`, and to
|
||||
/// `ITEM_BIND_QUEST` for the quest kinds; only then does the jump table `0x52e4fc` pick
|
||||
/// 1→picked up · 2→equipped · 3→used · 4/5→Quest Item.
|
||||
///
|
||||
/// Four claims, three of them controls: the override fires on a bound instance, an UNbound
|
||||
/// instance of the same item still reads *Binds when equipped*, a template hover (no instance at
|
||||
/// all) still reads *Binds when equipped*, and a bound QUEST item stays *Quest Item* — the
|
||||
/// override's other arm is the same text 4|5 print anyway.
|
||||
#[test]
|
||||
fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_item_template(871, axe()); // bonding 2 — Binds when equipped
|
||||
let ring = ItemTemplateView {
|
||||
name: "Maiden's Circle".into(),
|
||||
quality: 2,
|
||||
class: 4,
|
||||
subclass: 0,
|
||||
inventory_type: 11,
|
||||
bonding: 2,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_item_template(942, ring);
|
||||
let quest_item = ItemTemplateView {
|
||||
name: "Fresh Fish".into(),
|
||||
quality: 1,
|
||||
bonding: 4,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_item_template(4913, quest_item);
|
||||
let slot = |item_id: u32, already_bound: bool| ContainerSlot {
|
||||
count: 1,
|
||||
quality: Some(2),
|
||||
item_id,
|
||||
already_bound,
|
||||
..Default::default()
|
||||
};
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(1, slot(942, true)); // bound: the reported case
|
||||
slots.insert(2, slot(942, false)); // the same ring, not yet bound
|
||||
slots.insert(3, slot(4913, true)); // a bound QUEST item
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(Slot, "ANCHOR_RIGHT")
|
||||
tt:SetBagItem(0, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let bind_line = |s: &mut UiScript| {
|
||||
lines_of(s)
|
||||
.into_iter()
|
||||
.find(|(t, _)| t == "Soulbound" || t.starts_with("Binds when") || t == "Quest Item")
|
||||
.unwrap_or_else(|| panic!("no bind line at all"))
|
||||
};
|
||||
let (text, color) = bind_line(&mut s);
|
||||
assert_eq!(text, "Soulbound", "a runtime-bound instance overrides §6");
|
||||
assert_eq!(color, [1.0, 1.0, 1.0, 1.0], "§6 is white");
|
||||
|
||||
// Control 1: the SAME item, instance not bound — the template's bonding stands.
|
||||
s.run(r#"TT:SetBagItem(0, 2)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Binds when equipped");
|
||||
|
||||
// Control 2: a TEMPLATE hover carries no instance at all, so nothing can override.
|
||||
s.run(r#"TT:SetItemById(871)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Binds when equipped");
|
||||
|
||||
// Control 3: the override's other arm is ITEM_BIND_QUEST — the same text 4|5 already print.
|
||||
s.run(r#"TT:SetBagItem(0, 3)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Quest Item");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -60,11 +60,16 @@ fn item_set_block_counts_and_colors() {
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetItemById(6303)
|
||||
assert(TTTextLeft3:GetText() == "" or TTTextLeft3:GetText() == nil, "blank before the header")
|
||||
-- The spacer is the reference's own literal `0x854b2c` — a space and a newline, which is
|
||||
-- ONE row carrying one space, not the empty string (an empty AddLine adds no line at all
|
||||
-- in the reference: `0x530270` bails before its `inc [esi+0x31c]`).
|
||||
assert(TTTextLeft3:GetText() == " \n", "blank before the header, got "
|
||||
.. string.format("%q", tostring(TTTextLeft3:GetText())))
|
||||
assert(TTTextLeft4:GetText() == "Defias Leather (2/5)", "got " .. tostring(TTTextLeft4:GetText()))
|
||||
assert(TTTextLeft5:GetText() == " Defias Mark")
|
||||
assert(TTTextLeft8:GetText() == " Defias Boots", "in-flight member renders no line")
|
||||
assert(TTTextLeft9:GetText() == "" or TTTextLeft9:GetText() == nil, "blank before the bonuses")
|
||||
assert(TTTextLeft9:GetText() == " \n", "blank before the bonuses, got "
|
||||
.. string.format("%q", tostring(TTTextLeft9:GetText())))
|
||||
assert(TTTextLeft10:GetText() == "(2) Set: Increases movement speed slightly.",
|
||||
"thresholds sort ascending, got " .. tostring(TTTextLeft10:GetText()))
|
||||
assert(TTTextLeft11:GetText() == "(4) Set: +10 Attack Power.")
|
||||
|
||||
@@ -376,6 +376,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
creator: s.creator.clone(),
|
||||
has_text: false,
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
enchants: s.enchants.clone(),
|
||||
// `SetInventoryItem 0x532ee0` also has p6=0 legs (`0x533106`,
|
||||
// `0x5332ad`) — the "this binding can never emit OPENABLE" claim
|
||||
@@ -462,6 +463,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
creator: s.creator.clone(),
|
||||
has_text: s.readable,
|
||||
flags: s.flags,
|
||||
already_bound: s.already_bound,
|
||||
enchants: s.enchants.clone(),
|
||||
// p6 == 0 ⇔ no running cooldown. A clam shows the green line; the
|
||||
// same clam mid-cooldown would show ITEM_COOLDOWN_TIME instead
|
||||
|
||||
@@ -36,6 +36,13 @@ pub(super) struct ItemInstance {
|
||||
/// Instance `ITEM_FIELD_FLAGS` — the openable lock sub-gate reads UNLOCKED `0x4`
|
||||
/// (`0x52e30c`) and the wrapped-gift arm WRAPPED `0x8` (`0x52e31d`).
|
||||
pub flags: u32,
|
||||
/// `0x5da2c0` — **this instance is runtime-bound**: `ITEM_FIELD_FLAGS & 1`, or a live
|
||||
/// enchant slot naming a `SpellItemEnchantment` row that binds. The binding half needs a DBC
|
||||
/// join, so the whole predicate arrives app-resolved
|
||||
/// ([`crate::script::ContainerSlot::already_bound`]) rather than being re-derived here off
|
||||
/// [`Self::enchants`] — that list is a *display* view and drops rows the line law hides.
|
||||
/// Drives §6's Soulbound override; `false` on every template/link source.
|
||||
pub already_bound: bool,
|
||||
/// The instance's enchant slots, app-resolved and in slot order (law line 17 / §1-ENCHANT,
|
||||
/// decisions 0915/0920) — see [`crate::script::EnchantView`]. Empty on an unenchanted item and
|
||||
/// on every template/link source (no instance, nothing enchanted).
|
||||
@@ -62,6 +69,25 @@ pub(super) struct ItemInstance {
|
||||
pub openable_source: bool,
|
||||
}
|
||||
|
||||
/// The SET block's blank gold spacer — the reference's own literal `0x854b2c`, and it is **not**
|
||||
/// the empty string. It is a SPACE followed by a newline, and the difference is a whole row.
|
||||
///
|
||||
/// Both halves are byte-verified (wow-re, `tooltip-content-law.md` §22 + this arc's re-derivation):
|
||||
/// `AddLine`'s core `0x530270` takes an empty left text with no right text and **bails before it
|
||||
/// ever increments the line count** (`5302a9: test ebx,ebx; 5302ab: je 0x530378`, the shared exit,
|
||||
/// which never reaches the `inc [esi+0x31c]` at `0x530372`) — an empty string here is silently
|
||||
/// dropped, not even a zero-height slot. So the reference cannot spell this line `""`, and it
|
||||
/// doesn't: `" \n"` is one real line carrying one space. It measures as ONE row, not two, because
|
||||
/// the width-based stepper `0x5c7470` consumes the trailing break inside the same call that
|
||||
/// scanned the space (`5c7659: add esi,ecx`) and the kernel's next top-of-loop read hits the NUL.
|
||||
///
|
||||
/// benilla lands on the same row count from the same law: the app's `fontstring_lines`
|
||||
/// trailing-break rule (decision 1343) drops the empty segment a plain split would leave, so
|
||||
/// `" \n"` is one line here too. We wrote `String::new()`, which our own engine calls zero lines
|
||||
/// of zero height — so the set block's two spacers were invisible where the reference shows two
|
||||
/// blank rows.
|
||||
const SET_SPACER: &str = " \n";
|
||||
|
||||
/// Render one item template into the tooltip — the BYTE-VERIFIED emission law of the shared
|
||||
/// renderer `0x52b650` (wow-re `ui/scratch/tooltip-content-law.md`, §5-cross-checked 2026-07-10;
|
||||
/// the proficiency-cell and SET legs byte-read directly 2026-07-11; the creator/readable
|
||||
@@ -132,12 +158,22 @@ pub(super) fn render_view(
|
||||
if v.flags & 0x2 != 0 {
|
||||
add(("Conjured Item".into(), WHITE))?;
|
||||
}
|
||||
// The bind line (template bonding; the instance Soulbound override is a later feed).
|
||||
// The bind line (§6, white, one line). Bonding `[record+0x194]` ∈ {1..5} is what decides
|
||||
// whether a line prints at ALL — a Bonding-0 item says nothing here however it is held.
|
||||
// Within that, a **runtime-bound instance** (`0x5da2c0` — [`ItemInstance::already_bound`])
|
||||
// overrides the whole line to ITEM_SOULBOUND, and to ITEM_BIND_QUEST for the two quest
|
||||
// kinds, which is the same text 4|5 print anyway; only then does the jump table `0x52e4fc`
|
||||
// pick 1→picked up · 2→equipped · 3→used · 4/5→Quest Item.
|
||||
//
|
||||
// Before this arm an equipped Binds-when-equipped piece kept saying *Binds when equipped*
|
||||
// forever (B310, Frostshake): the template's `bonding` never changes when the item binds —
|
||||
// the instance's flag is the only thing that does.
|
||||
match v.bonding {
|
||||
4 | 5 => add(("Quest Item".into(), WHITE))?,
|
||||
1..=3 if inst.is_some_and(|i| i.already_bound) => add(("Soulbound".into(), WHITE))?,
|
||||
1 => add(("Binds when picked up".into(), WHITE))?,
|
||||
2 => add(("Binds when equipped".into(), WHITE))?,
|
||||
3 => add(("Binds when used".into(), WHITE))?,
|
||||
4 | 5 => add(("Quest Item".into(), WHITE))?,
|
||||
_ => {}
|
||||
}
|
||||
match v.max_count {
|
||||
@@ -446,10 +482,10 @@ pub(super) fn render_view(
|
||||
add((charges_phrase(v.charges as u32), WHITE))?;
|
||||
}
|
||||
// The item-SET block (§22, ABOVE the compact cut), byte-read at the builder's
|
||||
// `0x52d8a0..0x52e0f5`: a blank gold line, the gold "name (owned/total)" header, the
|
||||
// set-level skill line (white, red when short), the member ladder (" name" — pale-cream
|
||||
// `0xc0d368` when equipped, gray otherwise; a member whose template is still in flight
|
||||
// waits — the app re-pushes the view as names land), a second blank, then the threshold
|
||||
// `0x52d8a0..0x52e0f5`: a blank gold line ([`SET_SPACER`]), the gold "name (owned/total)"
|
||||
// header, the set-level skill line (white, red when short), the member ladder (" name" —
|
||||
// pale-cream `0xc0d368` when equipped, gray otherwise; a member whose template is still in
|
||||
// flight waits — the app re-pushes the view as names land), a second blank, then the threshold
|
||||
// bonuses "(N) Set: text" sorted THRESHOLD-ASCENDING (the builder qsorts the slot indices
|
||||
// via `0x52e5c0` — never the DBC's stored order), green only when the skill requirement
|
||||
// is met (`0x5eaae0`) AND owned ≥ threshold. "Owned" counts EQUIPPED members on both
|
||||
@@ -460,7 +496,7 @@ pub(super) fn render_view(
|
||||
.iter()
|
||||
.filter(|(id, _)| equipped.contains(id))
|
||||
.count();
|
||||
addw((String::new(), GOLD))?;
|
||||
addw((SET_SPACER.into(), GOLD))?;
|
||||
add((
|
||||
format!("{} ({}/{})", set.name, owned, set.members.len()),
|
||||
GOLD,
|
||||
@@ -482,7 +518,7 @@ pub(super) fn render_view(
|
||||
let color = if equipped.contains(id) { CREAM } else { GRAY };
|
||||
add((format!(" {name}"), color))?;
|
||||
}
|
||||
addw((String::new(), GOLD))?;
|
||||
addw((SET_SPACER.into(), GOLD))?;
|
||||
let mut bonuses: Vec<&(u32, String)> = set.bonuses.iter().collect();
|
||||
bonuses.sort_by_key(|&&(threshold, _)| threshold);
|
||||
for &(threshold, ref text) in bonuses {
|
||||
|
||||
@@ -599,7 +599,11 @@ impl RegionData {
|
||||
/// same-frame `GetWidth()` served the OLD header's width, and the edit box latched its text
|
||||
/// insets on it). The layout solver's own read (layout.rs) deliberately does NOT key-check:
|
||||
/// it keeps the last-known box until the fresh measure lands, so lines never collapse for the
|
||||
/// round-trip frame.
|
||||
/// round-trip frame. **With one carve-out, and it is the carve-out this cache needs**: EMPTY
|
||||
/// text is never measured at all (both measure asks filter it out), so "until the fresh
|
||||
/// measure lands" is a promise that can never be kept for a cell that goes from text to `""`
|
||||
/// — the solver drops the stored measure there rather than hold a dead box forever (B309: the
|
||||
/// item tooltip's blank SET spacers drew rows the plate counted as zero).
|
||||
/// `scale` is the owner frame's `effective_scale`: it's in the key because the host measures
|
||||
/// at the drawn raster size ([`MeasureRequest::scale`]), so a `SetScale` under a cached
|
||||
/// measure must invalidate it exactly like a font-size change.
|
||||
|
||||
Reference in New Issue
Block a user