feign death plays dead everywhere, and the attack keys go exclusive

- feign death plays dead for real: the body collapses and stays down, both bars read 0 and DEAD, the nameplate drops, the combat hum goes quiet
- melee and Auto Shot are exclusive now: starting one stops the other, even mid-swing
- another client's addon chatter no longer prints as party chat
- right-click presses look pressed, rage and happiness read on their real scale, and the login boxes blink their caret

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:00:00 +07:00
co-authored by Claude Fable 5
parent f17b303ffe
commit 5de1f3e7b8
36 changed files with 2615 additions and 188 deletions
Generated
+1
View File
@@ -596,6 +596,7 @@ dependencies = [
"bevy_egui",
"bytemuck",
"cosmic-text",
"cpal 0.17.1",
"crossbeam-channel",
"fontdb",
"half",
+4
View File
@@ -71,6 +71,10 @@ cosmic-text = "0.16"
# public-in-crate types). Pinned to the exact version `cosmic-text` 0.16 depends on so there's one
# `fontdb` in the graph.
fontdb = "0.23"
# kira's own backend dependency, named directly so the mixer can hand cpal an explicit device buffer
# size instead of accepting `BufferSize::Default` (decision 1026). `CpalBackendSettings`' fields ARE
# cpal types, so this must stay the exact version kira resolves — one cpal in the graph.
cpal = "0.17"
kira = { version = "0.12", default-features = false, features = ["cpal", "mp3", "wav", "pcm", "adpcm"] }
mint = "0.5"
# The gossip greeting is a weighted random DRAW per menu-open, not a fixed pick (wow-re
+12 -1
View File
@@ -27,6 +27,15 @@
<Script><![CDATA[
NUM_SHAPESHIFT_SLOTS = 10
-- RIGHT-CLICK IS DEAD HERE, and deliberately (decision 1030 reverting 1027). The ref's
-- ShapeshiftButtonTemplate registers NOTHING: it inherits ActionButtonTemplate and then
-- overrides <OnLoad> with a body that only scales the cooldown, so the
-- `RegisterForClicks("LeftButtonUp","RightButtonUp")` that ActionButton_OnLoad gives every
-- other action-style button (ref ActionButton.lua:109) is lost and the default
-- {LeftButtonUp} stands. A right-press therefore neither flashes the pushed art
-- (0x77924b gates that on the same mask) nor casts. It reads like a Blizzard oversight
-- and it is the only bar that behaves this way — reproduced anyway, because the director
-- checked it against the reference and called faithful.
function BenillaShapeshiftButton_OnLoad(button, index)
button.index = index
button.icon = getglobal(button:GetName() .. "Icon")
@@ -38,7 +47,9 @@
-- BonusActionBarFrame.xml l.31-38), so the checked ring never follows the click — only
-- the isActive repaint. That is what keeps a warrior's ACTIVE stance lit through the
-- click's silent wire no-op (stances never cancel — the 0x4b4963 guard), and a druid
-- form/Stealth lit until the server actually confirms the shift-out.
-- form/Stealth lit until the server actually confirms the shift-out. The revert is
-- unconditional, which is why no click here can leave a lit form dark — unlike the pet
-- bar's flat SetChecked(0), which needs a repaint to be correct.
function BenillaShapeshiftButton_OnClick(button)
if button:GetChecked() then
button:SetChecked(nil)
@@ -322,6 +322,22 @@
if ( canCoop == 0 ) then
UnitPopupShown[index] = 0;
end
elseif ( value == "FOLLOW" ) then
-- Ref UnitPopup.lua l.304-307, verbatim: you cannot follow a unit you cannot
-- cooperate with.
if ( canCoop == 0 ) then
UnitPopupShown[index] = 0;
end
elseif ( value == "INSPECT" ) then
-- Ref UnitPopup.lua l.316-319, verbatim. These two arms were MISSING
-- (decision 1033): without them FOLLOW and INSPECT stayed shown for *every*
-- unit — including one that does not exist — so PARTY's count never fell to
-- zero, `UnitPopup_ShowMenu`'s "nothing but CANCEL, don't show the menu"
-- early-out never fired at load, and each party dropdown's OnLoad-time
-- initialize ran on to its closing `PlaySound("igMainMenuOpen")`.
if ( canCoop == 0 ) then
UnitPopupShown[index] = 0;
end
elseif ( value == "INVITE" ) then
if ( dropdownMenu.unit ) then
if ( canCoop == 0 ) then
+42
View File
@@ -232,6 +232,48 @@ pub(crate) fn cancel_auto_repeat_local(
let _ = net.0.send(crate::net::ClientCommand::CancelAutoRepeat);
}
/// The one local **StopAttack** — benilla's `0x5ecac0` (wow-re §5 trio-verified,
/// `object-layer/scratch/melee-autorepeat-exclusion.md`): player-only, a no-op unless an attack
/// lock is held, then `CMSG_ATTACKSTOP` (its builder `0x624370` pushes opcode `0x142`) followed by
/// the tail jump `0x5ecb63 → CancelQueuedCast 0x6e6f30` — which takes the **queued on-next-swing
/// spell** down with the attack: `0x6e6f30`'s first leg tests the inflight id `[0xceca88]` for
/// `Attributes & 0x404` and hands it to `CancelCast 0x6e4940(dl=1, reason 0x1c)`, whose casting arm
/// sends `CMSG_CANCEL_CAST 0x12f` naming it and then pops the slot through `0x6e4ad0`.
///
/// This is the un-queue [`crate::ui_cast::QueuedMeleeSpell`] names as its real clear path, and it
/// is why a Raptor Strike ring goes dark the moment Auto Shot starts (the auto-repeat arm inside
/// the cast commit calls straight into here, `0x6e5976`, guarded by nothing but "the caster is the
/// active player").
///
/// The lock is our server-echoed [`Engaged`] — the ref's `[+0xc48]`, which it sets *locally* at
/// attack-start and which **StopAttack does not clear** (only the `SMSG_ATTACKSTOP` echo does,
/// `0x624e40`), so the Attack button's own ring survives this call in both clients. Two ref fields
/// are deliberately unmodelled, neither with a benilla reader: `[+0xc50]` ("locally initiated, not
/// server-confirmed") and `[+0xc54]` ("stop sent, awaiting the echo") — the latter's sole reader
/// image-wide is `0x5eccda`, inside `Attack 0x5ecb70`, which no benilla cast path enters while
/// engaged (see [`crate::ui_action::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
/// the stop are `TriggerTutorial(0xa)` / `(0xb)` — the low-health / low-mana popups, not audio and
/// not part of this seam.
pub(crate) fn stop_attack_local(
engaged: bool,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
net: &crate::net::NetCommands,
) {
if !engaged {
// The ref's `!IsAttacking(0x60ecb0) && [+0xc50] == 0` early-out: nothing running,
// nothing sent — and the queued strike, which cannot exist without a swing to fire it,
// survives untouched.
return;
}
let _ = net.0.send(crate::net::ClientCommand::AttackStop);
if let Some(spell_id) = queued_melee.current() {
let _ = net
.0
.send(crate::net::ClientCommand::CancelCast { spell_id });
queued_melee.clear_if(spell_id);
}
}
/// A unit is mid-cast (`SMSG_SPELL_START` .. `GO`/`FAILED_OTHER`, decision 0099 phase 1) — the
/// wire-truth state seam. Inserted on `SpellStart` only when it carries a nonzero cast time (an
/// instant cast's `SpellGo` follows with nothing to interrupt, so it never gets one); removed on
+22 -4
View File
@@ -483,10 +483,17 @@ pub(super) fn drive_animations(
// Which body a trace line is about — the rider's own, or the mount it is sitting on.
let subject = if mount_body.is_some() { "mount " } else { "" };
let walk = speeds.map_or(DEFAULT_WALK_SPEED, |s| s.0.walk);
// Dead ⇒ health 0 with a real max. Absent health counts as ZERO (`unit_is_dead`): a create
// block omits zero fields, so an already-dead corpse streams in with no HEALTH at all —
// requiring an explicit `Some(0)` here is exactly the bug where corpses stood up on relog.
let dead = store.is_some_and(|s| s.0.unit_is_dead());
// Dead ⇒ health 0 with a real max, **or** the dead-looking dynamic flag (`unit_reads_dead`
// — the reference's own `0x605f90`, decision 1022): feign death leaves health untouched and
// signals purely through `UNIT_DYNFLAG_DEAD`, and the reference runs it through this exact
// path — the flag's watcher `0x600440` calls `0x60ea30` (play Death) and the per-frame
// chain's top resolver `0x5fcff0` then claims and holds the corpse pose, the same pair the
// real death handler `0x625190` runs. So a hunter feigning collapses and lies there, for
// himself and for everyone watching, and stands back up when the flag clears.
// Absent health counts as ZERO: a create block omits zero fields, so an already-dead corpse
// streams in with no HEALTH at all — requiring an explicit `Some(0)` here is exactly the bug
// where corpses stood up on relog.
let dead = store.is_some_and(|s| s.0.unit_reads_dead());
// Mounted (decision 0441): `UNIT_FIELD_MOUNTDISPLAYID` nonzero — the wire's one mounted
// signal. The rider's base pins to Mount(91) below, Specials/one-shot full-body routes are
// suppressed, and the sheath reconcile force-stows; the locomotion the selector would have
@@ -571,6 +578,17 @@ pub(super) fn drive_animations(
// Death overrides every state (a corpse doesn't transition); play Death and hold.
if dead {
if drv.gait != Some(DEATH) {
// One line per collapse, naming WHICH death it is — a body lying down with full
// health is a feign, and without this the two are indistinguishable in a log.
debug!(
"death pose: unit {entity} arms Death ({DEATH}) — {}{}",
if store.is_some_and(|s| s.0.unit_is_dead()) {
"killed"
} else {
"feign (UNIT_DYNFLAG_DEAD, health intact)"
},
if first { ", streamed in dead" } else { "" }
);
if let Some(c) = find_resolved(anims, DEATH, catalog)
.or_else(|| find_resolved(anims, STAND, catalog))
{
+60 -2
View File
@@ -64,6 +64,7 @@ impl Plugin for LoginPlugin {
(
screen::materialize_screen,
login_input,
tick_login_caret,
screen::refresh_boxes,
screen::refresh_checkbox,
drive_dialog,
@@ -387,6 +388,9 @@ fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
};
form.account.set_text(&saved);
form.password.set_text("");
// `SetFocus` starts the caret solid — the screen never opens mid-blink-off (`set_text` alone
// wouldn't do it: it no-ops when the saved name is already in the box).
form.focused().reset_blink();
preview.scene = Some(GlueScene::MainMenu);
preview.look = None;
preview.yaw = 0.0;
@@ -428,8 +432,16 @@ fn login_input(
continue;
}
match action {
LoginAction::FocusAccount => form.focus = Field::Account,
LoginAction::FocusPassword => form.focus = Field::Password,
// A click takes the focus the same way TAB does — including the solid caret the fresh
// focus starts on, so the box you just clicked never answers with a blank half-period.
LoginAction::FocusAccount => {
form.focus = Field::Account;
form.focused().reset_blink();
}
LoginAction::FocusPassword => {
form.focus = Field::Password;
form.focused().reset_blink();
}
LoginAction::Login => do_login = true,
LoginAction::Quit => do_quit = true,
LoginAction::ToggleSave => {
@@ -518,6 +530,21 @@ fn login_input(
}
}
/// The caret's clock. Exactly one box owns the keyboard, so the focused one is the one that blinks
/// — on the shared law's 0.5 s period, so the login caret keeps time with the create-name box, the
/// delete dialog and the chat box (decision 0704). It keeps blinking under an open dialog: a dialog
/// eats the keys, not the clock.
///
/// Its own system, not a line inside [`login_input`]: a blink is a clock, not input, and as a
/// system it can be run on its own in a test — which is the only thing that can catch this tick
/// going missing again. It went missing once already, and nothing but an eye noticed: the box's
/// `caret_shown` simply never left its `true` default, so the login caret was the one glue caret
/// that sat solid.
fn tick_login_caret(mut form: ResMut<LoginForm>, time: Res<Time>) {
let dt = time.delta_secs();
textinput::tick_caret(form.focused(), true, dt);
}
/// Editing the account box away from the saved name clears the save + unchecks (the ref's
/// `OnTextChanged`).
fn on_account_edited(form: &mut LoginForm) {
@@ -794,4 +821,35 @@ mod tests {
assert_eq!(load_saved_account_from(&base), "");
let _ = std::fs::remove_dir_all(&base);
}
/// The clock system toggles the FOCUSED box on the shared 0.5 s period and leaves the other
/// one alone. It guards the system's behaviour, not its registration (that is the one
/// `tick_login_caret` line in [`LoginPlugin`], and a login screen with no clock is
/// indistinguishable from one whose caret is in its ON half forever — `caret_shown` defaults
/// to `true`, which is exactly how this went unnoticed).
#[test]
fn the_focused_box_caret_blinks() {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<LoginForm>()
.add_systems(Update, tick_login_caret);
let past_the_period = |app: &mut App| {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(600));
app.update();
app.world().resource::<LoginForm>().account.caret_shown
};
// Account has the focus by default; one period each way is on → off → on.
assert!(!past_the_period(&mut app), "the first period turns it off");
assert!(past_the_period(&mut app), "the second turns it back on");
// The box that doesn't own the keyboard never accumulates, so switching focus to it lands
// on a solid caret rather than wherever its own clock would have drifted to.
assert_eq!(
app.world().resource::<LoginForm>().password.blink_accum,
0.0
);
}
}
+123
View File
@@ -29,6 +29,49 @@ pub(super) fn chat(
net_commands: &NetCommands,
server_said: &mut MessageWriter<ServerSaidMessage>,
) {
// The ADDON gate (decision 1029, B215): a line whose `language` is `LANG_ADDON` is not speech
// at all — it is one addon talking to another over the party/raid/guild/channel lane, because
// 1.12.1 has no addon opcode and no addon `ChatMsg` type. The real client never renders it; it
// fires `CHAT_MSG_ADDON` instead. Rendering it printed a partied real-client player's
// `[Party] [Soreen]: Quiver VERSION:3.1.4` version ping as ordinary party chat, and a
// mixed-client party makes that constant background noise.
//
// VERIFIED in `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md`: the test is
// `0x49a89b`/`0x49a89e cmp edi,-0x1`, in the DISPLAY function `0x49a870`, and every branch of
// the addon arm `[0x49a8a7, 0x49a96d)` jumps below the normal render path at `0x49a970` — the
// chat frame is unreachable for such a line by construction. The **language field is the whole
// discriminator**: the type byte takes no part in the comparison, and the wire parser
// `0x49d560` never inspects language at all.
//
// **Ignore beats addon**, so that check comes first even though it costs a duplicated call:
// the reference drops an ignored sender's line at `0x49d72d`, *upstream* of the language test,
// so an ignored player's addon traffic fires nothing at all. Unobservable while both paths
// merely drop, but it is the precedence that has to already be right the day there is
// something to fire. (`CMSG_CHAT_IGNORED` is not owed here — the server refuses `LANG_ADDON`
// on WHISPER, the only type that answers on the wire.)
//
// **One divergence, named:** the reference suppresses *after* name resolution — an uncached
// sender parks the line in `__AUPENDINGCHAT__` (`0x49d7ba`) behind `CMSG_NAME_QUERY`, and
// `CHAT_MSG_ADDON` fires late from the callback `0x49ccc0`. We drop here instead, ahead of the
// name query [`ChatLog::push_wire`] would queue. Invisible today (nothing listens for
// `CHAT_MSG_ADDON` — benilla runs FrameXML, not third-party addons) and it saves a name query
// per addon sender; when an addon runtime lands, the *fire* must move downstream of the
// resolve so its `sender` arg is a name rather than a guid — the drop can stay here.
//
// The payload rides `debug!` and its own `addon` trace tag rather than vanishing, so
// mixed-client traffic stays diagnosable: invisible in chat, never invisible to us.
if m.is_addon() {
if !social.is_ignored(m.sender_guid) {
debug!(
"net: addon chat [{:#04x}] from {:#x}: {:?} (suppressed — not a chat line)",
m.chat_type, m.sender_guid, m.text
);
if crate::dbg_trace::enabled() {
crate::dbg_trace::line("addon", &format!("[{:#04x}] {:?}", m.chat_type, m.text));
}
}
return;
}
if m.chat_type == 0x0A {
info!("net: server says — {}", m.text);
// …and as a message, for the senders that must know whether their command landed. Server
@@ -131,3 +174,83 @@ pub(super) fn played_time(total: u32, level: u32, chat_log: &mut ChatLog) {
));
}
}
#[cfg(test)]
mod tests {
use bevy::ecs::system::RunSystemOnce;
use super::*;
use benilla_protocol::messages::{CHAT_MSG_PARTY, LANGUAGE_ADDON};
fn a_party_line(language: u32, text: &str) -> ChatMessage {
ChatMessage {
chat_type: CHAT_MSG_PARTY,
language,
sender_guid: 0x21,
target_guid: 0x21,
sender_name: None,
channel: None,
text: text.to_string(),
chat_tag: 0,
}
}
/// Run one inbound line through the real arm body and report whether it reached the chat log.
fn reaches_the_chat_window(m: ChatMessage) -> bool {
let mut world = World::new();
world.init_resource::<Messages<ServerSaidMessage>>();
let (tx, _rx) = crossbeam_channel::unbounded();
world.insert_resource(NetCommands(tx));
world.insert_resource(SocialState::default());
world.insert_resource(ChatLog::default());
world
.run_system_once(
move |mut chat_log: ResMut<ChatLog>,
social: Res<SocialState>,
net_commands: Res<NetCommands>,
mut server_said: MessageWriter<ServerSaidMessage>| {
chat(
m.clone(),
&mut chat_log,
&social,
&net_commands,
&mut server_said,
);
},
)
.unwrap();
world.resource::<ChatLog>().pending_len() > 0
}
/// B215 / decision 1029: an addon broadcast never reaches the chat window. It arrives as an
/// ordinary `CHAT_MSG_PARTY` — the lane a mixed-client party's hunter addon talks over — so the
/// `language` sentinel is the only thing standing between it and a rendered `[Party]` line.
#[test]
fn addon_chat_never_reaches_the_chat_window() {
assert!(
!reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, "Quiver\tVERSION:3.1.4")),
"an addon broadcast must be dropped before the chat log"
);
// A payload with no tab is still addon traffic — the gate reads the language field, never
// the shape of the text.
assert!(!reaches_the_chat_window(a_party_line(
LANGUAGE_ADDON,
"nopayloadseparator"
)));
// An empty addon payload likewise: nothing about the text can un-addon a line.
assert!(!reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, "")));
}
/// The control the gate must not swallow: real speech on the same lane, in every tongue a
/// character can speak. A gate that keyed on "unknown language" instead of the sentinel would
/// silently eat Demonic and Gutterspeak.
#[test]
fn speech_on_the_addon_lane_still_renders() {
for language in [0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] {
assert!(
reaches_the_chat_window(a_party_line(language, "party control line")),
"language {language} is a tongue — the line must render"
);
}
}
}
+7 -4
View File
@@ -73,10 +73,13 @@ pub(super) fn wobble(now_ms: u32, fraction: f32) -> f32 {
// The binary also holds a drunk FOV lane (camera channel `+0x10c`, setter `0x511250`, target
// `(179° − 90°)·fraction` into the projection — wow-re `drunk-camera-fov.md`), which benilla
// briefly shipped as a fisheye. The director's ref observation is that the real client shows NO
// fov change at any drunk value in normal third-person play — the lane exists but is gated dead
// in practice — so benilla renders none either (decision 1018; the gate's identity is wow-re's
// to pin).
// briefly shipped as a fisheye. The director's ref observation — the real client shows NO fov
// change at any drunk value in normal play — held: the lane's ease rate is the CVar
// `cameraFoVSmoothSpeed` (default 0.5°/s → a 178 s full swing whose zero-slope cosine onset
// restarts on every drunk-byte tick, creeping <1° before each reset — §5-verified, wow-re
// drunk-camera-fov.md §5). Effectively invisible in ordinary play, so benilla renders none
// (decision 1018). The one visible corner — entering world already drunk SNAPs the full
// fisheye instantly (camera-acquisition path) — is recorded there, deliberately unbuilt.
#[cfg(test)]
mod tests {
+10 -3
View File
@@ -68,7 +68,11 @@ fn death_vocals(
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
let dead = store.0.unit_is_dead();
// Reads-dead, not really-dead (decision 1022): the reference's `UNIT_DYNAMIC_FLAGS` watcher
// fires `0x623a40(4)` — the death vocal state — on the `UNIT_DYNFLAG_DEAD` set edge
// (`0x600543`), the very same call its real death handler makes (`0x6251b0`). So a feign
// drops the body with its death cry, exactly like a kill.
let dead = store.0.unit_reads_dead();
let was = known_dead.insert(entity, dead);
let fresh_death = was == Some(false) && dead;
if !fresh_death {
@@ -211,8 +215,11 @@ fn creature_body_loops(
.and_then(|d| voices.0.for_display(d))
.map(|v| v.loop_sound)
.unwrap_or(0);
let alive =
store.0.unit_health().unwrap_or(0) > 0 && store.0.unit_dynamic_flags() & 0x20 == 0;
// `0x623800`'s own gate verbatim (`0x623817` health, `0x62381e` the flag): RAW health ≤ 0
// — absent = 0, deliberately not `unit_is_dead`'s max-health guard, which would leave a
// unit whose snapshot has not landed humming — or `UNIT_DYNFLAG_DEAD` set, the feign-death
// bit the reference re-evaluates this very gate on (`0x60053c`, decision 1022).
let alive = store.0.unit_health().unwrap_or(0) > 0 && !store.0.unit_reads_dead();
let desired = if alive { kit } else { 0 };
// Stop a superseded loop first: death, or a mount transition that changed the row.
if let Some(&prev) = armed.get(&entity) {
+22 -6
View File
@@ -236,6 +236,19 @@ pub(super) fn play_kit_ext(
}
None => (None, mixer.play_2d(data)?),
};
// Every actual play, named. The one question the sound subsystem could not answer about itself
// was "what just made that noise?" — a report of an unexpected sound had no trace to read, only
// a guess at which trigger fired. `RUST_LOG=benilla_app::sound=debug` now answers it.
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",
},
if pos.is_some() { "3d" } else { "2d" },
);
out.channels.push(ActiveChannel {
kit: id,
source,
@@ -295,7 +308,7 @@ pub(super) fn set_source_kit_gain(out: &mut SoundOutput, source: Entity, kit_id:
pub(super) fn stop_source_kit(out: &mut SoundOutput, source: Entity, kit_id: u32) {
out.channels.retain_mut(|c| {
if c.source == Some(source) && c.kit == kit_id {
c.handle.stop(mixer::snap());
c.handle.stop(mixer::declick());
false
} else {
true
@@ -308,7 +321,7 @@ pub(super) fn stop_source_kit(out: &mut SoundOutput, source: Entity, kit_id: u32
pub(super) fn stop_source(out: &mut SoundOutput, source: Entity) {
out.channels.retain_mut(|c| {
if c.source == Some(source) {
c.handle.stop(mixer::snap());
c.handle.stop(mixer::declick());
false
} else {
true
@@ -493,14 +506,14 @@ pub(super) fn pump_channels(
// 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::snap(),
mixer::glide(),
);
return true;
};
let d_sq = math::dist_sq(listener, p);
if ch.cutoff > 0.0 && !math::audible(d_sq, ch.cutoff) {
// Beyond cutoff: the client virtualizes; our one-shots just stop (module docs).
ch.handle.stop(mixer::snap());
ch.handle.stop(mixer::declick());
return false;
}
let amp = config.category_amp(ch.category)
@@ -508,7 +521,10 @@ pub(super) fn pump_channels(
* ch.gain
* math::fmod_rolloff(d_sq, ch.min_dist)
* near_field(d_sq, ch.cutoff);
ch.handle.set_volume(mixer::amp_to_db(amp), mixer::snap());
// 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());
true
});
}
@@ -573,7 +589,7 @@ pub(super) fn apply_kit_debug(
fn stop_all_channels(mut out: NonSendMut<SoundOutput>) {
let n = out.channels.len();
for ch in &mut out.channels {
ch.handle.stop(mixer::snap());
ch.handle.stop(mixer::declick());
}
out.channels.clear();
if n > 0 {
+167 -8
View File
@@ -17,6 +17,8 @@
use anyhow::{Context, Result};
use bevy::prelude::*;
use cpal::traits::{DeviceTrait, HostTrait};
use kira::backend::cpal::CpalBackendSettings;
use kira::effect::reverb::{ReverbBuilder, ReverbHandle};
use kira::listener::ListenerHandle;
use kira::sound::streaming::StreamingSoundData;
@@ -26,15 +28,19 @@ use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Mix, Tw
pub(crate) use benilla_formats::SoundProvider;
// Types crossing the seam (consumers hold handles to stop/steer a playing channel; dropping
// a *sound* handle does not stop the sound, dropping a spatial *track* handle unloads its track).
// Types crossing the seam (consumers hold handles to stop/steer a playing channel; dropping a
// *sound* handle does not stop the sound, and dropping a spatial *track* handle marks its track
// for removal — which lands once the track's sounds finish, see `play_3d`'s
// `persist_until_sounds_finish`, so a fade-then-drop still gets to fade).
pub(crate) use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
pub(crate) use kira::sound::streaming::StreamingSoundHandle;
pub(crate) use kira::track::SpatialTrackHandle;
/// An immediate (zero-duration) parameter change — the per-frame sync tween. kira requires a tween
/// on every setter; the WoW-side ramps (volume rates, crossfades) are our own math updated per
/// frame, so the backend must not add smoothing of its own on top.
/// An immediate (zero-duration) parameter change. kira requires a tween on every setter; the
/// WoW-side ramps (volume rates, crossfades) are our own math updated per frame, so the backend
/// must not add smoothing of its own on top. Use this for a change that is genuinely a *step* —
/// a reverb preset switch (verified instant, `benilla-pins.md` A2), an initial value. For the
/// **per-frame volume feed** use [`glide`]: a step there is an audible click, not fidelity.
pub(crate) fn snap() -> Tween {
Tween {
duration: std::time::Duration::ZERO,
@@ -42,6 +48,47 @@ pub(crate) fn snap() -> Tween {
}
}
/// The per-frame **volume** feed's reconstruction ramp (decision 1026).
///
/// kira applies a track's volume as **one constant gain per internal chunk** — it updates the
/// parameter once per `internal_buffer_size` (128 frames ≈ 2.9 ms) block and multiplies the whole
/// block by it (`track/sub.rs::process`, `backend/renderer.rs`'s `chunks_mut`). Spatial *position*
/// is interpolated across the chunk (`time_in_chunk`); volume is not. So a [`snap`] on the
/// per-frame gain feed is a hard step in the waveform — a click whose loudness scales with the
/// size of the jump.
///
/// At a steady 60 fps the per-frame jumps are small and the clicks stay under the noise floor,
/// which is why this was invisible for so long. The moment frame pacing goes unstable — a
/// background build, an OBS encode, a macOS Space switch (decision 0609's world) — the jumps get
/// big and every live channel steps at once: the reported "crack fest". The frame rate was never
/// supposed to be audible.
///
/// So the feed glides instead of stepping: each frame starts a fresh linear ramp toward the value
/// we just computed. This is **not** backend smoothing layered on WoW's ramps — it is
/// reconstruction of a signal we only sample at frame rate. WoW's owned envelope math upstream is
/// untouched; a ramp shorter than one frame at 60 fps cannot smear a multi-second crossfade, and
/// on a long frame it simply completes early and holds.
pub(crate) fn glide() -> Tween {
Tween {
duration: std::time::Duration::from_millis(GLIDE_MS),
..Default::default()
}
}
/// [`glide`]'s ramp, and the de-click fade on a force-stop. Just under one 60 fps frame (16.7 ms):
/// long enough to bridge kira's 2.9 ms gain blocks, short enough that a stop still reads as
/// immediate and a live parameter never audibly lags its source.
const GLIDE_MS: u64 = 15;
/// The de-click fade for a **force-stop** — a `stop()` that cuts a channel which may still be at
/// full amplitude (a tracked loop reaped on despawn, the leave-world blanket stop). Ending a
/// non-zero waveform at an arbitrary sample is a step to zero: the same click [`glide`] fixes on
/// the gain feed. One `GLIDE_MS` ramp removes it without moving the stop in time. The fade
/// survives dropping the handle — `stop_fade_ramps_after_handle_drop` pins exactly that.
pub(crate) fn declick() -> Tween {
glide()
}
/// A linear fade over `ms` — kira's default easing, matching the client's constant per-tick volume
/// decrement (`0x7a5a50`). Used to fade a stream out before it stops, or a bed out before it swaps
/// (the outgoing side of a music transition / ambience swap).
@@ -72,6 +119,8 @@ pub(crate) fn amp_to_db(amp: f32) -> Decibels {
pub(crate) struct Mixer {
manager: AudioManager<DefaultBackend>,
listener: ListenerHandle,
/// Rolling mix-health counters — the crackle instrument ([`Mixer::poll_health`]).
health: MixHealth,
/// The zone-reverb send track (wet-only Freeverb). Every 3D world track routes into it at
/// build; this handle's volume is the zone wet level (SILENCE = reverb off).
reverb_send: SendTrackHandle,
@@ -83,7 +132,10 @@ 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> {
let settings = AudioManagerSettings::<DefaultBackend>::default();
let settings = AudioManagerSettings::<DefaultBackend> {
backend_settings: backend_settings(),
..Default::default()
};
let mut manager = AudioManager::<DefaultBackend>::new(settings)
.map_err(|e| anyhow::anyhow!("audio device init: {e}"))?;
// The zone-reverb send: wet-only (the dry path stays on the source tracks), silent until
@@ -118,6 +170,7 @@ impl Mixer {
Ok(Self {
manager,
listener,
health: MixHealth::default(),
reverb_send,
reverb,
})
@@ -170,9 +223,13 @@ impl Mixer {
);
}
/// Master volume as linear amplitude (the whole mix — every track routes to main).
/// Master volume as linear amplitude (the whole mix — every track routes to main). Fed every
/// frame, so it [`glide`]s: a step on the *main* track clicks the entire mix at once, and this
/// is the knob a slider drag and the mute toggle both move.
pub(crate) fn set_master(&mut self, amp: f32) {
self.manager.main_track().set_volume(amp_to_db(amp), snap());
self.manager
.main_track()
.set_volume(amp_to_db(amp), glide());
}
/// Play a decoded (short SFX) sound on the main track — the 2D/UI path.
@@ -204,6 +261,16 @@ impl Mixer {
// volume, so per-zone reverb is one knob, not a per-track update.
SpatialTrackBuilder::new()
.attenuation_function(None)
// Outlive the handle by exactly as long as the sound needs (decision 1026).
// kira defaults this to `false` (`track/sub/spatial_builder.rs`), which makes
// `should_be_removed()` fire the instant the handle drops — so a channel that
// is stopped-with-a-fade and then dropped in the same breath (every reaper
// here: cutoff cull, despawn, leave-world) loses its track mid-ramp and gets
// cut anyway. `declick()` would have been a no-op on all 3D audio. With this
// set, the drop only *marks* the track and kira keeps it until its sounds
// finish. Cannot leak: every path that drops a channel stops its sound first,
// and a stopped sound finishes.
.persist_until_sounds_finish(true)
.with_send(&self.reverb_send, Decibels(0.0)),
)
.map_err(|e| anyhow::anyhow!("spatial track alloc: {e}"))?;
@@ -226,6 +293,98 @@ impl Mixer {
}
}
/// 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
/// conferencing tool) can drag it down under us and we would never know. Naming it puts a floor
/// under the callback's deadline instead of inheriting someone else's.
///
/// 1024 frames is ~21 ms at 48 kHz. Latency that size is inaudible for WoW — nothing here is
/// rhythm-critical, the shortest UI click is an order of magnitude longer — and it buys ~4× the
/// deadline of a 256-frame buffer for the mix to finish in.
const TARGET_BUFFER_FRAMES: u32 = 1024;
/// Build the cpal backend settings: kira's default device, our explicit buffer size.
///
/// `device` stays `None` on purpose — that keeps kira's own default-device selection *and* its
/// disconnect/restart handling (`custom_device = false`). We override only the config. Every
/// failure path falls back to kira's defaults, so a machine we can't probe still opens the device
/// exactly as before; [`Mixer::new`]'s caller already tolerates no-device.
fn backend_settings() -> CpalBackendSettings {
let fallback = CpalBackendSettings::default();
let Some(device) = cpal::default_host().default_output_device() else {
return fallback;
};
let Ok(supported) = device.default_output_config() else {
return fallback;
};
// Clamp into what the device will actually accept; `Unknown` means cpal can't tell us the
// range, and asking for a size outside it fails the stream build — so leave those alone.
let cpal::SupportedBufferSize::Range { min, max } = supported.buffer_size() else {
info!("audio: device reports no buffer-size range — leaving it to the driver");
return fallback;
};
let frames = TARGET_BUFFER_FRAMES.clamp(*min, *max);
let mut config = supported.config();
config.buffer_size = cpal::BufferSize::Fixed(frames);
info!(
"audio: {} Hz, {} ch, buffer {frames} frames (~{:.1} ms)",
config.sample_rate,
config.channels,
f64::from(frames) / f64::from(config.sample_rate) * 1000.0,
);
CpalBackendSettings {
config: Some(config),
..fallback
}
}
/// The mix-health counters — what a crackle actually *is*, in numbers (decision 1026).
///
/// Before this, a crackle was invisible to us: the only report was the director's ear, and we
/// could not tell a missed callback deadline from a stepped parameter from a starved decoder.
/// kira hands us the measurement and we were not reading it.
#[derive(Default, Clone, Copy, Debug)]
pub(crate) struct MixHealth {
/// The most recent callback load: elapsed / allotted, where allotted is `frames / sample_rate`
/// (kira's `pop_cpu_usage`). **`>= 1.0` means the mix missed its deadline** — that is an
/// underrun, and an underrun is audible as a crack.
pub(crate) load: f32,
/// Worst load seen since the last [`Mixer::take_health_peak`].
pub(crate) peak_load: f32,
/// Callbacks at/over the deadline, since launch.
pub(crate) overruns: u64,
/// cpal stream errors kira handled, since launch (device loss, `BufferUnderrun`).
pub(crate) stream_errors: u64,
}
impl Mixer {
/// Drain the backend's health queues. Cheap and non-blocking (two ring-buffer pops per
/// frame); the queues are bounded, so *not* draining them is what loses information.
pub(crate) fn poll_health(&mut self) -> MixHealth {
let backend = self.manager.backend_mut();
while let Some(load) = backend.pop_cpu_usage() {
self.health.load = load;
self.health.peak_load = self.health.peak_load.max(load);
if load >= 1.0 {
self.health.overruns += 1;
}
}
while let Some(err) = backend.pop_error() {
self.health.stream_errors += 1;
warn!("audio: stream error — {err}");
}
self.health
}
/// Read and reset the peak — so a report covers the window since the last one, not all time.
pub(crate) fn take_health_peak(&mut self) -> f32 {
std::mem::take(&mut self.health.peak_load)
}
}
/// Move a live spatial track's emitter — the tracked-channel follow (the client's `0x61fec0`
/// tracked play): the kit pump drives this each frame for a source-tagged loop so the sound
/// rides its unit. Free function (not a `Mixer` method) because the pump holds only the track
+70 -6
View File
@@ -59,9 +59,13 @@ pub(crate) struct SoundConfig {
pub enabled: bool,
/// Quick mute — toggled by the dev chord + `M` (its plane is per-OS: decisions 0585, 0867).
/// Zeroes the **main track only**: selection and channel life go on untouched, so unmute is
/// instant
/// (unlike `enabled`, which stops sounds from being picked at all). Starts `true`: the client
/// boots silent until deliberately unmuted.
/// instant (unlike `enabled`, which stops sounds from being picked at all).
///
/// Starts **`false`** — a run you launch has sound, like the real client (decision 1026). It
/// used to boot muted so that automated runs stayed quiet, but that made the human's every
/// session start with a chord press to hear anything. The quiet belongs on the automated side
/// instead: `$WOW_NOSOUND` (agents) and `$WOW_CAPTURE` (the visual harness) open no device at
/// all — see [`SoundPlugin`].
pub muted: bool,
/// Master volume, linear `[0,1]` (the whole mix, applied on the main track).
pub master: f32,
@@ -99,7 +103,7 @@ impl Default for SoundConfig {
fn default() -> Self {
Self {
enabled: true,
muted: true,
muted: false,
master: 1.0,
sfx: 1.0,
music: 0.4,
@@ -162,8 +166,21 @@ pub(crate) struct SoundPlugin;
impl Plugin for SoundPlugin {
fn build(&self, app: &mut App) {
let mixer = if std::env::var_os("WOW_NOSOUND").is_some() {
info!("$WOW_NOSOUND set — audio disabled");
// Who gets sound: a run a human launched, and only that. The default posture is audible
// (decision 1026 — `SoundConfig::muted` starts false), so the silence has to be opt-in by
// the *automated* callers, both of which are unattended by construction:
// $WOW_NOSOUND — an agent opening the client to check something (the dispatch recipe in
// `method.md` sets it; nobody is listening, and a background window that
// starts playing zone music is pure noise in a shared room).
// $WOW_CAPTURE — the visual harness. A screenshot has no audio track; opening a device
// per capture is cost and racket for nothing.
// Both open no device at all rather than muting one, so an unattended run cannot grab the
// audio hardware or hold it against the session the director is actually listening to.
let silent = ["WOW_NOSOUND", "WOW_CAPTURE"]
.into_iter()
.find(|v| std::env::var_os(v).is_some());
let mixer = if let Some(var) = silent {
info!("${var} set — audio disabled");
None
} else {
match Mixer::new() {
@@ -188,6 +205,7 @@ impl Plugin for SoundPlugin {
update_audio_listener.in_set(WorldStage::Stream),
toggle_mute,
apply_master_volume.after(toggle_mute),
poll_mix_health,
),
);
kit::plugin(app);
@@ -274,3 +292,49 @@ fn apply_master_volume(
mixer.set_master(if cur.0 { cur.1 } else { 0.0 });
}
}
/// How long a window of missed deadlines is summarised into one line. Long enough that a sustained
/// problem reports steadily instead of flooding, short enough to still correlate with what the
/// director was doing when they heard it.
const MIX_HEALTH_REPORT: std::time::Duration = std::time::Duration::from_secs(5);
/// Drain the backend's mix-health queues and report deadline misses (decision 1026).
///
/// This is the instrument the crackle investigation had to be run without: kira measures every
/// callback's `elapsed / allotted` and we were throwing it away, so an underrun — the one failure
/// that is *definitionally* audible — left no trace anywhere but the director's ear. The queues
/// are bounded ring buffers, so polling every frame is also what keeps them meaningful.
///
/// A miss is not a maybe: `load >= 1.0` means the mix did not finish before the driver needed it,
/// and the driver played whatever was in the buffer. If this line is quiet during a crackle, the
/// crackle is *not* an underrun and the next suspect is upstream (a stepped parameter, a starved
/// stream decoder) — which is exactly the disambiguation we could not make before.
fn poll_mix_health(
mut out: NonSendMut<SoundOutput>,
time: Res<Time>,
mut since_report: Local<std::time::Duration>,
mut last_overruns: Local<u64>,
) {
let Some(mixer) = out.mixer.as_mut() else {
return;
};
let health = mixer.poll_health();
*since_report += time.delta();
if *since_report < MIX_HEALTH_REPORT {
return;
}
*since_report = std::time::Duration::ZERO;
let peak = mixer.take_health_peak();
let new_overruns = health.overruns - *last_overruns;
*last_overruns = health.overruns;
if new_overruns > 0 {
warn!(
"audio: {new_overruns} missed mix deadline(s) in the last {}s (peak load {:.0}% of \
budget) — this is what a crackle sounds like",
MIX_HEALTH_REPORT.as_secs(),
peak * 100.0,
);
} else {
debug!("audio: mix load peak {:.0}% of budget", peak * 100.0);
}
}
+5 -3
View File
@@ -422,16 +422,18 @@ fn zone_audio(
}
// ---- per-frame volumes (sliders are live) ----
// Both feeds `glide` rather than snap (decision 1026): these are the two loudest, longest-lived
// channels in the mix, so a stepped gain on either is the most audible click there is.
// Music holds full kit × category volume every frame — its only transition fade is the
// outgoing 4.0 s fade-stop (on the backend), and the incoming starts at full (faithful).
if let Some(h) = &mut zone.music {
h.set_volume(
mixer::amp_to_db(config.category_amp(SoundCategory::Music) * zone.music_kit_vol),
mixer::snap(),
mixer::glide(),
);
}
// Ambience runs the incoming leg of its 5.0 s crossfade as a per-frame fade-in envelope (the
// per-frame snap would otherwise stomp the ramp to full); it clears itself at full.
// per-frame feed would otherwise stomp a handle-level ramp to full); it clears itself at full.
let ambience_gain = fade_in_gain(&mut zone.ambience_fade_in, now);
if let Some(h) = &mut zone.ambience {
h.set_volume(
@@ -440,7 +442,7 @@ fn zone_audio(
* zone.ambience_kit_vol
* ambience_gain,
),
mixer::snap(),
mixer::glide(),
);
}
}
+4 -2
View File
@@ -62,8 +62,10 @@ pub(crate) use lock::GO_FLAG_LOCKED;
// nameplate colour gate (`crate::nameplates`), the flash's only two consumers (byte-verified).
pub(crate) use flash::CombatFlash;
// The action layer's "attack pressed with no target" request — the nearest-enemy auto-acquire
// (`scan`) answers it with the same core TAB uses.
pub(crate) use scan::{can_attack, AttackNearestRequest};
// (`scan`) answers it with the same core TAB uses. `attack_order_target` + `EnemyScan` are that
// same core called *synchronously*, by the pet bar's ATTACK arm: the pet's order has to leave in
// the frame it was pressed carrying the acquired guid, so it cannot go round through a request.
pub(crate) use scan::{attack_order_target, can_attack, AttackNearestRequest, EnemyScan};
// The chat layer's by-name selection asks (`/target`, `/assist` — decision 0886), answered by the
// shared resolver the reference parameterises per caller.
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
+228 -23
View File
@@ -224,9 +224,13 @@ pub(super) fn load_creature_types(mut commands: Commands, world_assets: Option<R
}
/// Everything the scan core reads, bundled (the TAB and attack-acquire systems share it).
///
/// `pub(crate)` rather than `pub(super)` because the **pet bar's** ATTACK arm runs the same
/// `TargetNearestEnemy()` the player's does ([`attack_order_target`]), and its drain lives in
/// `ui_pet`.
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(super) struct EnemyScan<'w, 's> {
pub(crate) struct EnemyScan<'w, 's> {
units: Query<
'w,
's,
@@ -267,11 +271,10 @@ pub(super) struct EnemyScan<'w, 's> {
impl EnemyScan<'_, '_> {
/// The `0x493e40` mode-1 validity filter — liveness + hostility (kept byte-law).
fn is_valid(&self, store: Option<&ObjectStore>, self_store: Option<&ObjectStore>) -> bool {
if store.is_some_and(|s| {
s.0.unit_is_dead()
|| s.0.unit_dynamic_flags() & (1 << 5) != 0
|| s.0.unit_stand_state() == 7
}) {
// The liveness leg is the reference's own reads-dead triple `0x605f90` — health, the
// `UNIT_DYNFLAG_DEAD` bit (feign death) and stand state 7 — now the shared predicate
// rather than a third transcription of it (decision 1022).
if store.is_some_and(|s| s.0.unit_reads_dead()) {
return false;
}
can_attack(
@@ -282,6 +285,46 @@ impl EnemyScan<'_, '_> {
)
}
/// Our own descriptor, for whichever leg needs the reaction's second party outside [`build`].
fn self_store(&self) -> Option<&ObjectStore> {
self.self_q.single().ok().and_then(|(_, store, _)| store)
}
/// `0x6130a3`'s keep-or-drop test on a guid we are already holding: is the actor hostile to it?
///
/// The reference reads `0x6061e0(actor, target) >= 4` — the **reaction alone**, not the full
/// `0x606980` CanAttack, which it saves for the final gate at `0x613167`. So this is
/// [`ring_reaction`] and nothing else. A guid whose unit is not streamed answers `false`,
/// which is the same exit `0x613099` takes when its object lookup misses.
///
/// **The actor is the player here, and in the reference it is the caller's** — the pet on the
/// pet arm (`0x4bd40d` passes the pet object). The two agree: vmangos gives a pet its owner's
/// faction template outright (`Pet.cpp:248`), and the reputation leg is the owner's by
/// definition. Reading the pet's own store instead would also fight [`build`], whose entire
/// candidate scan is player-relative (screen, distance, "fighting me").
fn reaction_hostile(&self, guid: u64) -> bool {
let self_store = self.self_store();
self.units
.iter()
.find(|(_, _, g, _, _, _)| g.0 == guid)
.is_some_and(|(_, _, _, _, store, _)| {
ring_reaction(
self.factions.as_deref(),
&self.reputations,
store,
self_store,
) <= 3
})
}
/// The streamed unit behind a guid, as `(entity, store)` — the final gate's input.
fn unit_by_guid(&self, guid: u64) -> Option<(Entity, Option<&ObjectStore>)> {
self.units
.iter()
.find(|(_, _, g, _, _, _)| g.0 == guid)
.map(|(e, _, _, _, store, _)| (e, store))
}
/// The full build: walk every known unit, filter (the kept 1.12 legality laws), project
/// through the live camera, score, sort (tier, then score). Fresh every press — the live
/// world is the list (no snapshot to go stale).
@@ -586,6 +629,138 @@ pub(super) fn tab_target(
}
}
/// `0x6130b5` — **`TargetNearestEnemy()` itself**, and the whole of "pressing Attack at nothing
/// finds something in front of you".
///
/// The acquire the validator runs is not a private helper: it is `0x493f60(ecx = 0, edx = 1)`, the
/// same cycler the four Lua `TargetNearest*` bindings drive (`0x489a80`–`0x489af3` are the same
/// call with `edx = 1..4` for enemy / friend / party / raid, and `ecx` the *reverse* flag). So
/// "in front of me" is not a separate rule with its own cone — it is this module's priority scan,
/// which is why the acquire commits through [`commit`] like any press: `CMSG_SET_SELECTION` goes
/// out and **your** target really moves, which is what lets the pet's order and your own swing
/// aim at the same thing.
///
/// Returns the committed `(entity, guid)`, or `None` after raising `0x6130d9`'s
/// `ERR_NO_ATTACK_TARGET`.
///
/// Divergence, deliberate and inherited: the reference's cycler keeps a cached, time-expiring
/// candidate list and a cursor into it, so a *repeated* acquire walks; ours re-scores the live
/// world every call and always hands back the head. That is decision 0567's trade for the whole
/// module (history cycling replaces the snapshot cursor), and an acquire is a fresh pick in both.
fn acquire_nearest_enemy(
scan: &EnemyScan,
selection: &mut Selection,
net: &NetCommands,
errors: &mut crate::ui_action::UiErrorKeys,
) -> Option<(Entity, u64)> {
let cands = scan.build();
let Some(c) = cands.first() else {
// `0x6130d9` — the acquire ran and came back empty, so the selection re-read at `0x6130c1`
// still finds nothing: errorId `0xa0` `ERR_NO_ATTACK_TARGET`.
debug!("attack acquire: nothing to attack (ERR_NO_ATTACK_TARGET)");
errors
.0
.push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET"));
return None;
};
// `engaged = false`: the switch law only fires when we were already swinging at the OLD
// selection, and every path that reaches here had no selection or a non-hostile one — neither
// is a thing you can be engaged with.
commit(selection, net, c.entity, c.guid, false, None, false);
Some((c.entity, c.guid))
}
/// `0x612df0`'s **Phase B** — the attack order's *target* arm, and the reason pressing the pet's
/// Attack button at nothing still sends the pet at something.
///
/// Phase A (the actor's own eligibility — [`crate::ui_action::attack_actor_refusal`]) has already
/// run by the time this is reached; what is left is choosing **what** to attack. The candidate the
/// caller passes is `{0,0}` for a bare press, and it gets replaced by the current selection twice
/// over — once by `CastPetAction` itself for a nil Lua argument (`0x4bd212`), once by the
/// validator for a `{0,0}` candidate (`0x61306b`) — so the candidate *is* the selection:
///
/// ```text
/// 0x61306b candidate == 0 -> candidate = the current selection
/// 0x61309b 0x6061e0(actor, candidate) -> the actor's reaction toward it
/// 0x6130a3 reaction >= 4 -> candidate = 0 (friendly or neutral is not a target)
/// 0x613100 else 0x493540(candidate) ; promote it into the selection (a dedup when it came from there)
/// 0x6130b5 candidate == 0 -> 0x493f60(0, 1) == TargetNearestEnemy()
/// 0x6130c1 re-read the selection ; whatever the acquire committed
/// 0x6130d9 still nothing -> ERR_NO_ATTACK_TARGET, EAX = 0, no packet
/// 0x613167 final gate -> ERR_INVALID_ATTACK_TARGET, EAX = 0, no packet
/// ```
///
/// Two things fall out that are worth knowing before looking at it in game. **Pressing Attack
/// while you hold a friendly target retargets you** — the reaction test drops it and the acquire
/// runs, so your selection moves to the nearest enemy. And the guid this returns is what goes in
/// the packet: the validator writes its answer back through the caller's out-param (`0x6130c6`),
/// and `0x4bd491` reads that slot when it builds `CMSG_PET_ACTION`.
///
/// The final gate is transcribed rather than folded into the reaction test above it, because the
/// reference deliberately keeps them apart: a *dead* selection is `ERR_INVALID_ATTACK_TARGET`, not
/// a reason to go find something else. `0x613159`'s odd second leg is verbatim — a zero-health
/// target passes iff `UNIT_DYNAMIC_FLAGS` bit 5 is set.
pub(crate) fn attack_order_target(
scan: &EnemyScan,
selection: &mut Selection,
net: &NetCommands,
errors: &mut crate::ui_action::UiErrorKeys,
) -> Option<u64> {
// `0x61306b` + `0x6130a3`: the selection is the candidate, and it survives only if the actor is
// hostile to it. Otherwise `0x6130b5` acquires.
let guid = match keeps_held_target(selection.guid, |g| scan.reaction_hostile(g)) {
Some(kept) => kept,
None => acquire_nearest_enemy(scan, selection, net, errors)?.1,
};
// `0x613167`'s final gate on whatever we ended up with. `0x61312e`'s legs are the ACTOR's and
// are Phase A's, already run by the caller; these two are the target's.
let store = scan.unit_by_guid(guid).and_then(|(_, s)| s);
if !attack_target_valid(
store,
scan.factions.as_deref(),
&scan.reputations,
scan.self_store(),
) {
debug!("attack order: {guid:#x} fails the final gate (ERR_INVALID_ATTACK_TARGET)");
errors
.0
.push(crate::ui_action::UiError::key("ERR_INVALID_ATTACK_TARGET"));
return None;
}
Some(guid)
}
/// `0x6130a3`'s fork on its own: keep what you are holding, or fall through to the acquire.
///
/// The whole content is that **the reaction alone decides**, and it decides against the *actor*.
/// A friendly or neutral selection is not "an invalid target" — the reference zeroes the candidate
/// (`0x6130a8`) and goes looking, which is why pressing pet-Attack while you have a quest giver
/// selected moves your target to the nearest mob instead of raising an error.
fn keeps_held_target(selection: Option<u64>, hostile: impl Fn(u64) -> bool) -> Option<u64> {
selection.filter(|&g| hostile(g))
}
/// `0x613152`–`0x613169` — the final gate's two **target** legs, whatever route the guid arrived
/// by.
///
/// - alive, transcribed verbatim including its odd second half: a zero-health target passes iff
/// `UNIT_DYNAMIC_FLAGS` bit 5 is set (`0x613159 shr edx,5; test dl,1`);
/// - `CanAttack 0x606980` — the full predicate this time, not the bare reaction the fork above uses.
///
/// A target with no streamed descriptor passes both, matching [`can_attack`]'s own missing-data
/// posture: nothing known to disqualify is not a disqualification.
fn attack_target_valid(
store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
self_store: Option<&ObjectStore>,
) -> bool {
let alive = store.is_none_or(|s| {
s.0.unit_health().is_none_or(|h| h > 0) || s.0.unit_dynamic_flags() & (1 << 5) != 0
});
alive && can_attack(store, factions, reputations, self_store)
}
/// Request from the action layer: the attack action fired with NO selection — pick the best
/// candidate and swing at it (`0x612df0` @ `6130b5`).
#[derive(Message)]
@@ -631,24 +806,14 @@ pub(super) fn acquire_and_attack(
) {
return;
}
let cands = scan.build();
let Some(c) = cands.first() else {
// `0x6130d9` — the acquire ran and came back empty, so the selection re-read at `0x6130c1`
// still finds nothing: errorId `0xa0` `ERR_NO_ATTACK_TARGET`. Logged only until wow-re
// named the key (`pet-command-validators.md` §2); it is a red line, like every other
// `ERR_ATTACK_*` in that registry run.
debug!("attack acquire: nothing to attack (ERR_NO_ATTACK_TARGET)");
ui_error_keys
.0
.push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET"));
// `0x6130b5` and `0x6130d9`, shared with the pet bar's ATTACK arm — see
// [`acquire_nearest_enemy`]. The selection is empty (checked above), which is exactly the
// `candidate == 0` leg of [`attack_order_target`]; the follow-through below is what differs.
let Some((_, guid)) = acquire_nearest_enemy(&scan, &mut selection, &net, &mut ui_error_keys)
else {
return;
};
debug!(
"attack acquire: best candidate {:#x} → select + swing",
c.guid
);
// Selection was empty (checked above), so the engaged-switch law can't apply — plain commit.
commit(&mut selection, &net, c.entity, c.guid, false, None, false);
debug!("attack acquire: best candidate {guid:#x} → select + swing");
// The attack start (`0x6131a0` continuation): auto-draw (snap, decision 0080) + the swing.
if let Ok(e) = self_player.single() {
sheath.write(crate::creature_anim::SheathRequest {
@@ -657,7 +822,7 @@ pub(super) fn acquire_and_attack(
ceremony: false,
});
}
let _ = net.0.send(ClientCommand::AttackSwing { guid: c.guid });
let _ = net.0.send(ClientCommand::AttackSwing { guid });
}
/// Auto-acquire the attacker (behavior 2): the ATTACKERSTATEUPDATE victim handler's
@@ -900,6 +1065,46 @@ mod tests {
assert_eq!(pick_forward(&[], &[], None), None);
}
/// **Phase B's fork** (`0x6130a3`): a hostile selection is kept, everything else acquires.
///
/// The case that reads wrong until you have read the binary is the *friendly* one — it is not
/// an error, it is a reason to go find a real target. That is what makes "press Attack with no
/// target and the pet goes for what is in front of you" the same code path as "press Attack
/// while talking to a vendor and your target jumps to the wolf behind him".
#[test]
fn phase_b_keeps_only_a_hostile_selection() {
let hostile = |g: u64| g == 0xBAD;
assert_eq!(keeps_held_target(Some(0xBAD), hostile), Some(0xBAD));
assert_eq!(keeps_held_target(Some(0x600D), hostile), None, "friendly");
assert_eq!(keeps_held_target(None, hostile), None, "no selection");
}
/// The final gate's target legs (`0x613152`–`0x613169`), including the transcribed oddity: a
/// zero-health target is invalid **unless** dynamic-flag bit 5 is set.
#[test]
fn the_attack_orders_final_gate_reads_health_then_can_attack() {
use benilla_protocol::ObjectFields;
const HEALTH: u16 = 22;
const DYNFLAGS: u16 = 143;
const FLAGS: u16 = 46;
let reps = Reputations::default();
let unit = |pairs: &[(u16, u32)]| ObjectStore(ObjectFields::from_pairs(pairs));
let valid = |s: &ObjectStore| attack_target_valid(Some(s), None, &reps, None);
assert!(valid(&unit(&[(HEALTH, 120)])), "a live mob");
assert!(!valid(&unit(&[(HEALTH, 0)])), "a corpse");
assert!(
valid(&unit(&[(HEALTH, 0), (DYNFLAGS, 1 << 5)])),
"0x613159's second leg, verbatim"
);
assert!(
!valid(&unit(&[(HEALTH, 120), (FLAGS, 1 << 25)])),
"NOT_SELECTABLE is one of CanAttack's disqualifiers"
);
// No descriptor is no disqualification — `can_attack`'s own posture, unchanged.
assert!(attack_target_valid(None, None, &reps, None));
}
/// The history: pruning honors the window, a re-visit moves to most-recent.
#[test]
fn history_prunes_and_reorders() {
+217 -4
View File
@@ -527,7 +527,7 @@ fn send_spell_cast(
}
}
if let Some(d) = def {
if let Ok((e, _)) = self_player.single() {
if let Ok((e, engaged)) = self_player.single() {
if d.ranged_attack() {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
@@ -540,6 +540,17 @@ fn send_spell_cast(
// The live autorepeat key (`0xceac30 = SpellRec+0x00` at `0x6e5947`) — what the
// button's flash/checked state reads until CANCEL_AUTO_REPEAT clears it.
auto_repeat.0 = Some(spell_id);
// The commit's own **StopAttack** (`0x6e5976`, guarded by nothing but "the caster
// is the active player" — the inlined guid compare at `0x6e5957`–`0x6e5972` that
// `0x5ecac0` then re-does): starting an auto-repeat ends the melee auto-attack and
// takes the queued on-next-swing strike down with it. This is the end at which
// the client's melee ⟷ auto-repeat exclusion is actually enforced (decision
// 1029): the attack-start tail below cancels a repeat only on a press that
// *starts* the swing, so this call is what keeps "swinging and auto-shooting at
// once" unreachable — and it is why the reference un-checks both the Attack
// button and a queued Raptor Strike the instant Auto Shot starts. The arm above
// runs first, so the repeat we just started is never the thing this cancels.
crate::creature_anim::stop_attack_local(engaged, queued_melee, commands);
}
}
}
@@ -588,6 +599,17 @@ fn send_spell_cast(
if let (Some(d), Some(guid)) = (def, target) {
if d.initiates_auto_attack() {
if let Ok((e, engaged)) = self_player.single() {
// **`!engaged` is the TAIL's own gate, not StartAttack's** — the distinction cost
// decision 1028 a wrong half. `Attack 0x5ecb70`'s already-attacking test at
// `0x5eccda` really does skip only the `CMSG_ATTACKSWING` send, jumping *into*
// the tail at `0x5ecd78` whose `0x5ecd8c` cancels the auto-repeat — but a cast
// press never reaches that jump. TryCast's tail gates the CALL: `6e51cb call
// 0x60ecb0; 6e51d2 jne` skips `0x6131a0` outright when an attack is already
// running (wow-re `combat-feel-law.md` §A1, and `melee-autorepeat-exclusion.md`
// §0a, which states the consequence outright: a strike pressed while already
// engaged does nothing to a running auto-repeat). So the whole block is gated,
// and the exclusion holds from the other end instead — the commit's StopAttack
// above means an auto-repeat can't be running while we swing in the first place.
if !engaged {
debug!("ui_action: cast {spell_id} initiates auto-attack at {guid:#x}");
sheath.write(crate::creature_anim::SheathRequest {
@@ -595,9 +617,9 @@ fn send_spell_cast(
state: 1,
ceremony: false,
});
// Melee attack-start cancels a running auto-repeat UNCONDITIONALLY — the
// client's `0x5ecd8c` tail right after its melee snap (wow-re
// `nocked-ammo-cancel.md` §Q-B-5): you can't melee and auto-shoot at once.
// `0x5ecb70`'s tail `0x5ecd78`–`0x5ecd95`: the melee sheath snap
// (`0x611cf0(1,1,1)`) then the local auto-repeat cancel (`0x6ea080` at
// `0x5ecd8c`) — you can't start swinging and keep auto-shooting.
crate::creature_anim::cancel_auto_repeat_local(
Some(e),
auto_repeat,
@@ -683,6 +705,197 @@ mod tests {
.expect("the ladder runs as a one-shot system");
}
// ── The melee ⟷ auto-repeat exclusion (the 5875 image, read 2026-08-06) ──────────────────
//
// Two client-local calls make Auto Shot and a queued Raptor Strike mutually exclusive, and
// benilla had neither shape right: the commit's auto-repeat arm calls **StopAttack**
// (`0x6e5976` → `0x5ecac0` → `CancelQueuedCast 0x6e6f30`), and the melee attack-start's tail
// (`0x5ecd78`–`0x5ecd95`) cancels the running repeat **whether or not** the
// already-attacking test at `0x5eccda` skipped the swing send. The tests below pin both, and
// the two orders they compose into.
const AUTO_SHOT: u32 = 75;
const RAPTOR_STRIKE: u32 = 2973;
const MOB: u64 = 0x0000_0000_0000_00f1;
/// The two combat-initiation classes exactly as the real 5875 rows classify them (the data
/// itself is pinned by `benilla_formats`' `real_spell_catalog_classifies_combat_initiation`):
/// Auto Shot is `AttributesEx2 & 0x20` auto-repeat over `Attributes & 0x2` ranged, Raptor
/// Strike is `Attributes & 0x404` on-next-swing. `Targets & 0x2` is the generic UNIT bind —
/// the one candidate leg that needs no relation data ([`cast_target::clear_satisfied_bits`]),
/// so the selection binds here without a faction table standing in for the world.
fn combat_catalog() -> Spells {
use std::collections::HashMap;
let auto_shot = benilla_formats::SpellDisplay {
targets: 0x2,
attributes: 0x2,
attributes_ex2: 0x20,
..Default::default()
};
let raptor_strike = benilla_formats::SpellDisplay {
targets: 0x2,
attributes: 0x404,
..Default::default()
};
Spells {
catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([
(AUTO_SHOT, auto_shot),
(RAPTOR_STRIKE, raptor_strike),
])),
..Spells::empty_for_tests()
}
}
/// [`world`] plus the catalog and the self player the commit tail needs — `engaged` is our
/// server-echoed mirror of the ref's attack lock `[+0xc48]`.
fn combat_world(engaged: bool) -> (World, Receiver<ClientCommand>) {
let (mut world, rx) = world();
world.insert_resource(combat_catalog());
let mut me = world.spawn(SelfPlayer);
if engaged {
me.insert(crate::creature_anim::Engaged);
}
(world, rx)
}
fn send_at(world: &mut World, spell_id: u32, target: u64) {
world
.run_system_once(move |mut ladder: CastLadder| {
let ctx = cast_target::CastContext {
selection_guid: Some(target),
..ctx()
};
ladder.send(spell_id, &ctx, CastCommit::Spell);
})
.expect("the ladder runs as a one-shot system");
}
/// **The commit's StopAttack** (`0x6e5976`): starting an auto-repeat ends the melee swing and
/// takes the queued on-next-swing strike with it — `CMSG_ATTACKSTOP` from `0x624370`, then the
/// tail jump into `CancelQueuedCast 0x6e6f30`, whose `Attributes & 0x404` leg hands the queued
/// id to `CancelCast 0x6e4940(dl=1)` and its `CMSG_CANCEL_CAST`. Both buttons cannot be lit at
/// once; benilla used to leave the strike's ring on because this call did not exist here.
#[test]
fn an_auto_repeat_press_stops_the_attack_and_takes_the_queued_strike_with_it() {
let (mut world, rx) = combat_world(true);
world
.resource_mut::<crate::ui_cast::QueuedMeleeSpell>()
.arm(RAPTOR_STRIKE);
send_at(&mut world, AUTO_SHOT, MOB);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::AttackStop)),
"the auto-repeat arm calls StopAttack before the cast leaves"
);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CancelCast { spell_id }) if spell_id == RAPTOR_STRIKE),
"the StopAttack tail cancels the queued strike by id"
);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == AUTO_SHOT),
"and then the shot itself commits"
);
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
None,
"the queue is empty — the strike's checked ring goes dark"
);
}
/// `0x5ecac0`'s early-out (`!IsAttacking && [+0xc50] == 0`): with no swing running there is
/// nothing to stop and nothing is sent — a queued strike, which cannot exist without a swing
/// to fire it, survives untouched.
#[test]
fn an_auto_repeat_press_with_no_swing_running_stops_nothing() {
let (mut world, rx) = combat_world(false);
world
.resource_mut::<crate::ui_cast::QueuedMeleeSpell>()
.arm(RAPTOR_STRIKE);
send_at(&mut world, AUTO_SHOT, MOB);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == AUTO_SHOT),
"the shot, and only the shot"
);
assert!(rx.try_recv().is_err(), "no ATTACKSTOP, no CANCEL_CAST");
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
Some(RAPTOR_STRIKE)
);
}
/// **The other direction, and the correction decision 1028 needed** (wow-re §5,
/// `melee-autorepeat-exclusion.md` §0a + `combat-feel-law.md` §A1). `Attack 0x5ecb70`'s tail
/// really does cancel the auto-repeat on a path that skipped the swing send — but a cast press
/// never reaches it while engaged: TryCast's tail gates the CALL, `6e51cb call 0x60ecb0;
/// 6e51d2 jne`, so `0x6131a0` is not invoked at all. A strike pressed mid-swing therefore does
/// nothing to a running auto-repeat, and 1028's un-gated tail was wrong to make it.
///
/// The state this pins is unreachable in ordinary play *because* of the sibling test above —
/// starting Auto Shot stops the swing — which is the point: the exclusion is enforced at one
/// end, not both.
#[test]
fn a_strike_pressed_while_already_swinging_reaches_no_attack_start_at_all() {
let (mut world, rx) = combat_world(true);
world.resource_mut::<AutoRepeatActive>().0 = Some(AUTO_SHOT);
send_at(&mut world, RAPTOR_STRIKE, MOB);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == RAPTOR_STRIKE)
);
assert!(
rx.try_recv().is_err(),
"no ATTACKSWING and no CANCEL_AUTO_REPEAT — `6e51d2 jne` skipped the whole attack-start"
);
assert_eq!(
world.resource::<AutoRepeatActive>().0,
Some(AUTO_SHOT),
"the repeat survives: the reference cancels it via the attack it STARTS, and it started none"
);
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
Some(RAPTOR_STRIKE),
"the strike still queues — only the attack-start was skipped"
);
}
/// The same press from the state that *is* reachable: not yet swinging. Here the tail fires,
/// `0x6131a0` → `0x5ecb70` runs, and its `0x5ecd8c` kills the repeat on the way to the swing —
/// the transitive route all three §5 workers missed by censusing StartAttack's direct callers.
#[test]
fn a_strike_pressed_before_the_swing_starts_kills_the_auto_repeat() {
let (mut world, rx) = combat_world(false);
world.resource_mut::<AutoRepeatActive>().0 = Some(AUTO_SHOT);
send_at(&mut world, RAPTOR_STRIKE, MOB);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == RAPTOR_STRIKE)
);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CancelAutoRepeat)),
"the attack-start tail's `0x6ea080`"
);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::AttackSwing { guid }) if guid == MOB),
"and then the swing itself"
);
assert_eq!(
world.resource::<AutoRepeatActive>().0,
None,
"Auto Shot's ring goes dark"
);
}
/// **B200's regression test, at the ladder** (decisions 0908/0914): an item use is a cast
/// through the same `TryCast` a spell press runs, so the in-flight rung (`6e4d97`) refuses the
/// double-click — the same spell silently (`6e4d43`), a different one with 0x61 — and neither
+12 -3
View File
@@ -185,9 +185,18 @@ impl PendingCast {
/// (`6e4d43`: debug-log, `xor al,al`, no CMSG, no error): 1.12 has no re-press-to-unqueue. The
/// real un-queue is the StopAttack chain (`0x5ecac0` → `CancelQueuedCast 0x6e6f30`, sending
/// `CMSG_ATTACKSTOP` + `CMSG_CANCEL_CAST`), reached by /stopattack, the Attack-button toggle,
/// target change/death, mount, interact — never movement. benilla doesn't run that chain yet
/// (we have no attack-toggle-off or stop-attack-on-target-change law) — a named follow-up; the
/// wire still resolves the queue either way.
/// **an auto-repeat press** (`0x6e5976`), target change/death, mount, interact — never movement.
/// That chain is [`crate::creature_anim::stop_attack_local`], and the auto-repeat arm inside
/// [`crate::ui_action::cast_send`]'s commit runs it whole: starting Auto Shot un-queues the
/// strike, which is what darkens its checked ring. **The two target-side StopAttack senders do
/// not route through it yet** — `target::scan`'s `commit` (the engaged-switch law) and
/// `target::click`'s `clear` still emit a bare `CMSG_ATTACKSTOP`, so a target switch or a
/// click-off leaves the queue standing where the reference cancels it. The same two sites owe a
/// second edge the 2026-08-06 §5 pinned: a mid-melee target switch reaches `Attack 0x5ecb70`
/// (`SetSelection 0x493540` @ `0x4938c8`, gated on `0x60ecb0` already true) and therefore runs
/// its `0x5ecd8c` **auto-repeat cancel** — refuting `nocked-ammo-cancel.md`'s direct-callers-only
/// census. Threading the seam through those eight callers is the named follow-up; the wire still
/// resolves the queue either way.
#[derive(Resource, Default)]
pub(crate) struct QueuedMeleeSpell(Option<u32>);
+7
View File
@@ -170,6 +170,13 @@ impl ChatLog {
pub(crate) fn clear_session(&mut self) {
self.pending.clear();
}
/// How many items are queued — the gates upstream of this log ([`crate::net::apply`]'s addon
/// and ignore gates) are "the line never got here at all", so their tests read this.
#[cfg(test)]
pub(crate) fn pending_len(&self) -> usize {
self.pending.len()
}
}
/// The ding's chat lines — the reference PLAYER_LEVEL_UP handler transcribed
+380 -100
View File
@@ -81,29 +81,57 @@ pub(crate) struct PetBar {
/// Seeded from the `SMSG_PET_SPELLS` tail and topped up by `SMSG_SPELL_COOLDOWN` addressed to
/// the pet's guid.
pub(crate) cooldowns: Cooldowns,
/// **"The pet is attacking"** — the client's own `[0xb714b0]`, and a purely local latch with
/// no field behind it anywhere (wow-re §1).
/// **"The possessed unit is attacking"** — the client's own `[0xb714b0]`, a purely local latch
/// with no field behind it anywhere (wow-re §1), and **the possess bar's, not the pet bar's**.
///
/// It has exactly two writers, and reproducing them is the whole of `IsPetAttackActive`: set
/// by an ATTACK command press (`0x4bd42e`), and cleared by `PetStopAttack`'s core `0x4bd650`
/// (`0x4bd6ae`) plus the pet-guid change (`0x4bc8ce`) — a new pet is not attacking.
/// It is `IsPetAttackActive` entire, and `GetPetActionInfo`'s COMMAND branch ORs it into
/// `isActive` for action 2 (`0x4bdf16`–`0x4bdf22`), so it is also the only thing that can ever
/// light an Attack button. What gates the one write that raises it is the whole story
/// ([`possessing`]): `0x4bd420` demands `0x5ee5a0(player) == the bar's unit`, and `0x5ee5a0`
/// answers the **possessed** unit or nothing. A hunter's pet is not possessed, so on an
/// ordinary pet bar this latch **can never rise** — which is exactly the director's report:
/// Attack is a button you press, never a mode that stays lit.
///
/// **`0x4bd650` has two call sites, not one**, and missing the second is what made the Attack
/// button read as a mode here when it is an order in the reference: the Lua `PetStopAttack`
/// (the lit button's second press), and the **old-target clear `0x493910` at `0x493a18`**,
/// which every selection writer runs. [`pet_stop_on_old_target_clear`] carries the second.
/// The three ways down are transcribed anyway, because under possession they are live: the lit
/// button's own second press (Lua `PetStopAttack` → `0x4bd650`), a new pet (`0x4bc8ce`), and
/// the **old-target clear `0x493910` at `0x493a18`** ([`pet_stop_on_old_target_clear`]), which
/// every selection writer runs.
///
/// benilla read this off the pet's streamed `UNIT_FIELD_TARGET` until the RE landed. That is a
/// different question with a different answer: a defensive pet that retaliates on its own has
/// a target the player never ordered, and the reference does not light the Attack button for
/// it.
/// it either.
///
/// **It is not the source of `PET_ATTACK_START`/`PET_ATTACK_STOP`**, however much it reads like
/// one — decision 0990 made exactly that inference and it was wrong. Those ride the pet's
/// server-owned `UNIT_FIELD_FLAGS` bit `0x800` instead ([`feed_pet_unit`]). This latch answers
/// "did the player order an attack", the flag answers "is the pet fighting", and the pet's own
/// initiative is the gap between them.
/// "did the player order the unit they are *driving* to attack"; the flag answers "is the pet
/// fighting", and it is the flag that moves for a hunter.
pub(crate) attacking: bool,
/// **`SignalEvent(0x161 PET_BAR_UPDATE)`, counted** — the half of a press that is not the state
/// write and matters just as much.
///
/// Both state writers end on an **unconditional** signal, with no compare of the old value
/// against the new one:
///
/// ```text
/// 0x4bc940 [0xb71468] = state & 0xFFFFFF00 | action ; ecx = 0x161 ; jmp SignalEvent (reaction)
/// 0x4bc960 [0xb71468] = state & 0x080000FF | a << 8 ; ecx = 0x161 ; jmp SignalEvent (command)
/// ```
///
/// That is not redundancy, it is load-bearing, and missing it is visible on the first click:
/// the CheckButton widget flips itself before `OnClick` runs and the reference's first line is
/// `this:SetChecked(0)`, so **every** press starts by taking the light off. What puts it back
/// is `PetActionBar_Update` running on this signal and re-deriving `isActive`. Press the mode
/// the pet is *already* in and nothing about the state changes — but the signal still fires and
/// the light still comes back.
///
/// benilla's feed dedups on what it last pushed ([`PetBarMemory`]), which is right for a
/// per-frame feed and wrong here: a no-change press pushed nothing, fired nothing, and left the
/// button visually un-toggled with no way back until something else moved. So the counter is
/// folded into the dedup key — a bump is a forced repaint, which is exactly what the signal
/// buys the reference. Wrapping, because only its *changes* are ever read.
pub(crate) bar_signals: u32,
}
impl PetBar {
@@ -136,9 +164,12 @@ fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a real change rather than every frame
/// (the [`crate::ui_shapeshift`] memory pattern).
///
/// The leading `u32` is [`PetBar::bar_signals`], and it is what keeps the dedup from swallowing a
/// press that changes nothing — see that field for why the reference cannot afford to skip one.
#[derive(Default)]
struct PetBarMemory {
pushed: Option<(bool, bool, bool, Vec<PetActionView>)>,
pushed: Option<(u32, bool, bool, bool, Vec<PetActionView>)>,
}
pub(crate) struct UiPetPlugin;
@@ -241,17 +272,22 @@ fn slot_paint(
.then(|| command_token(action))
.flatten()
{
// A command token lights on `(state >> 8) == action` **or** on the attack latch
// (wow-re §2.3, `0x4bdf03`-`0x4bdf22`). Two things live in that `or`:
// A command token lights on `(state >> 8) == action` **or** on the attack latch — read at
// the bytes at `0x4bdf01`-`0x4bdf22`, and both halves matter:
//
// - the compare is against the UNMASKED `state >> 8` (`PetSpells::command_state`'s own
// note), so a disabled bar puts every command button out;
// - ATTACK gets the extra clause, which is why the pet can be *told* to Stay and still
// show Attack lit while it is on something.
// - ATTACK gets the extra clause — and it is the *only* thing that can light ATTACK,
// because the command byte is never written for it (§10.1).
//
// So whether Attack ever appears lit is entirely a question about [`PetBar::attacking`],
// and the answer for a pet bar is **never**: that latch is the possess bar's, gated on
// `0x5ee5a0` ([`possessing`]). This expression stays faithful rather than hard-coding the
// `false`, because under Mind Control or Eyes of the Beast the same button does light.
//
// `attack_active` — `IsPetAttackActive`, the click fork — is the same latch narrowed to
// this slot (wow-re §5: type 7, action 2, and the flag). Same input, so the button that
// lights is exactly the button whose next press calls the pet off.
// this slot (`0x4be138`-`0x4be153`: type 7, action 2, and the flag). Same input, so the
// button that lights is exactly the button whose next press calls the unit off.
let attacking = pet_attacking && action == PET_COMMAND_ATTACK;
return PetActionView {
name: Some(name.to_string()),
@@ -425,16 +461,19 @@ fn feed_pet_bar(
Vec::new()
};
if memory.pushed.as_ref() != Some(&(has_bar, usable, pickup_allowed, fresh.clone())) {
// `bar.bar_signals` rides the key so a press the state does not move still repaints — the
// `0x4bc940`/`0x4bc960` signal, which the button's own `SetChecked(0)` makes mandatory.
let key = (bar.bar_signals, has_bar, usable, pickup_allowed, fresh);
if memory.pushed.as_ref() != Some(&key) {
debug!(
"ui_pet: bar {} ({} occupied slot(s), {}{})",
if has_bar { "shown" } else { "hidden" },
fresh.iter().filter(|s| s.name.is_some()).count(),
if usable { "usable" } else { "disabled" },
if pickup_allowed { "" } else { ", possessed" },
if key.1 { "shown" } else { "hidden" },
key.4.iter().filter(|s| s.name.is_some()).count(),
if key.2 { "usable" } else { "disabled" },
if key.3 { "" } else { ", possessed" },
);
memory.pushed = Some((has_bar, usable, pickup_allowed, fresh.clone()));
script.set_pet_actions(has_bar, usable, pickup_allowed, fresh);
memory.pushed = Some(key.clone());
script.set_pet_actions(key.1, key.2, key.3, key.4);
script.fire_event("PET_BAR_UPDATE", vec![]);
}
}
@@ -632,14 +671,16 @@ fn feed_pet_unit(
///
/// The optimism is bounded by the same packet that owns everything else — the next
/// `SMSG_PET_SPELLS` (a re-summon, a learn, a stable swap) replaces state and contents together.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
fn drain_pet_actions(
script: Option<NonSendMut<UiScript>>,
mut bar: ResMut<PetBar>,
selection: Res<Selection>,
mut selection: ResMut<Selection>,
commands: Res<NetCommands>,
pet: PetUnit,
spells: Option<Res<Spells>>,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
scan: crate::target::EnemyScan,
) {
let Some(mut script) = script else {
return;
@@ -656,11 +697,21 @@ fn drain_pet_actions(
debug!("ui_pet: dropping queued pet intents — the bar is gone");
return;
}
let target_guid = selection.guid.unwrap_or(0);
// NOTE, so it is not "fixed" again (decision 1030 reverting 1027): the repaint is
// [`latch_press`]'s alone, and it is deliberately NOT unconditional on the click.
// `PetActionButton_OnClick`'s `this:SetChecked(0)` runs for every click, but the reference only
// signals `PET_BAR_UPDATE` from the writes that changed something — so a **refused**
// `TogglePetAutocast` (`0x4bcbf7`: a token is not autocastable, which is every right-click on
// Follow, Stay or a reaction) leaves that button's ring off until the next repaint from
// anywhere. That is the reference's own behaviour, checked there by the director, and
// reproduced.
//
// The pet's own descriptor — the ATTACK arm's validator reads it, and so does the spell arm's
// showing-active test. Neither is about the bar; both are about the pet.
let pet_store = pet.store(pet_guid);
// `0x4bd420`'s compare, hoisted once: is the bar's unit the one we are *driving*? Only then may
// an ATTACK press latch (see [`possessing`]) — which for an ordinary pet is never.
let possessing = possessing(pet_store, pet.self_guid.0);
for slot in pressed {
let Some(entry) = slot_entry(&bar, slot) else {
@@ -682,12 +733,34 @@ fn drain_pet_actions(
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
continue;
}
// The ATTACK order's validator runs FIRST, and its actor is the pet — see
// [`commit_press`], which owns what a veto actually costs.
let refused = is_attack_order(entry)
&& crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, &mut ui_errors);
if !commit_press(&mut bar, entry, refused) {
debug!("ui_pet: slot {slot} refused by the pet's own state — no packet");
// `0x612df0`, the ATTACK order's validator, and its actor is the **pet**. Phase A is the
// pet's own eligibility; Phase B is the target, and it can move your selection — see
// [`crate::target::attack_order_target`], which is the whole of "press Attack at nothing
// and the pet goes for whatever is in front of you".
//
// Every other arm sends the selection untouched: `CastPetAction` substitutes it for a nil
// Lua argument at `0x4bd212` and only the ATTACK arm ever calls the validator, so a Follow
// or a spell carries whatever you happened to have.
let mut target_guid = selection.guid.unwrap_or(0);
let refused = if is_attack_order(entry) {
crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, &mut ui_errors)
|| match crate::target::attack_order_target(
&scan,
&mut selection,
&commands,
&mut ui_errors,
) {
Some(guid) => {
target_guid = guid;
false
}
None => true,
}
} else {
false
};
if !commit_press(&mut bar, entry, refused, possessing) {
debug!("ui_pet: slot {slot} refused by the attack validator — no packet");
continue;
}
debug!(
@@ -778,8 +851,10 @@ fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> bool {
previous.is_some() && previous != now
}
/// **Clearing the old target calls `PetStopAttack`'s core**, and that is why the Attack button is
/// not the sticky toggle Stay and Follow are.
/// **Clearing the old target calls `PetStopAttack`'s core** — the third and last way the attack
/// latch goes down, and live only on a **possess** bar (see [`possessing`]; a hunter's pet never
/// raises the latch in the first place, so [`stop_pet_attack`]'s own gate makes this a no-op for
/// it, exactly as `0x4bd65e` does in the reference).
///
/// `0x493910` — the old-target clear, run by *both* selection writers (`0x493540`'s switch calls
/// it with the outgoing guid at `ecx = 0`, the explicit clear with `{0,0}` at `ecx = 1`) — does
@@ -787,18 +862,15 @@ fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> bool {
///
/// ```text
/// 0x493a08 0x5ecac0(player) ; StopAttack -> CMSG_ATTACKSTOP (target/scan.rs `commit`)
/// 0x493a0f 0x5ee5a0(player) ; MY own pet, or null
/// 0x493a0f 0x5ee5a0(player) ; the unit I am POSSESSING, or null
/// 0x493a18 0x4bd650() ; PetStopAttack's core, iff there is one <- THIS
/// 0x493a1d if (notifyServer) ... ; CMSG_SET_SELECTION
/// ```
///
/// The pet call sits **above** the notify-server branch, so it runs on the silent switch-clear
/// too, not only on the explicit one. Its effect is `[0xb714b0] = 0` — and `GetPetActionInfo`'s
/// COMMAND branch lights ATTACK on `action == 2 && [0xb714b0] != 0` and nothing else (wow-re §2.3;
/// the command byte is never written for ATTACK, §10.1). So in the reference the light survives
/// exactly until the next time you touch your target, which is what makes an *order* read
/// differently from a *mode*: Stay and Follow persist because they live in the command byte, and
/// Attack does not because it lives in a latch three separate things knock down.
/// The `0x5ee5a0` at `0x493a0f` is the same possession resolver `0x4bd420` uses to gate the latch,
/// which is the tidy proof that the whole latch arm belongs to one bar: the thing that raises it
/// and the thing that clears it ask the identical question. The call also sits **above** the
/// notify-server branch, so it runs on the silent switch-clear too, not only on the explicit one.
///
/// Modelled as a transition on [`Selection`] rather than a hook in the selection writers because
/// that is what the reference gets for free by routing every writer through one clear: a `Local`
@@ -835,22 +907,24 @@ fn pet_stop_on_old_target_clear(
/// **Only STAY and FOLLOW reach the command write.** The binary's type-7 arm gates it on
/// `action <= 1` (§10.1): DISMISS (3) falls straight through, and ATTACK (2) leaves down the
/// validation chain that ends at `[0xb714b0] = 1` (`0x4bd42e`) — the attack latch, never the
/// command byte. The distinction is the difference between an order and a mode: Stay and Follow
/// are what the pet is *doing until told otherwise*; Attack is a thing you *tell it once*, and its
/// light comes from the latch — which the lit button's own second press, a new pet, and **every
/// change of your target** ([`pet_stop_on_old_target_clear`]) each knock down.
/// command byte. The distinction is the difference between a mode and an order: Stay and Follow
/// are what the pet is *doing until told otherwise*, and they stay lit because `isActive`'s state
/// compare keeps matching the command byte. Attack is a thing you tell it once.
///
/// Getting this wrong is visible within one click: latching ATTACK into the command byte leaves
/// the Attack button lit forever (`isActive`'s state compare keeps matching), blanks Follow and
/// Stay, and survives the `PetStopAttack` that should have put it out.
/// the Attack button lit forever and blanks Follow and Stay.
///
/// Reaching here at all means the press already cleared the client's two ATTACK gates, because
/// [`drain_pet_actions`] runs them first — so the latch is still raised unconditionally, and now
/// that is *correct* rather than a residual. Both gates are carved
/// (`object-layer/scratch/pet-command-validators.md`): `0x612df0` refuses on the **pet's** own
/// state and never reaches the send, and `0x5ee5a0` re-derives the pet from the player's own
/// descriptor. The second is structurally true whenever we hold a bar — the bar's guid *came* from
/// the server naming us this pet's controller — so only the first is a test we can fail.
/// **The ATTACK arm's latch is gated on possession** — [`possessing`], the `0x5ee5a0` compare at
/// `0x4bd420`, which no ordinary pet passes. So on a hunter's bar this match arm never fires and
/// the Attack button never checks, which is what the reference does and what the director sees.
/// The arm is still written out rather than deleted because the possess bar is the same bar: Mind
/// Control and Eyes of the Beast reach it, and everything downstream (`IsPetAttackActive`,
/// `PetStopAttack`, the old-target clear) is live for them.
///
/// The other gate on this press, `0x612df0`, ran back in [`drain_pet_actions`] and is *not*
/// symmetric with it: a validator veto costs the whole press (`0x4bd414` jumps to the epilogue,
/// no packet), while failing the possession compare costs only the latch (`0x4bd427` jumps to the
/// send). [`commit_press`] holds that asymmetry.
/// What the `PET_ATTACK_*` field-change callback would see for this unit: `Some(fighting)` when
/// the unit is **ours**, `None` when it is nobody's business of ours and no event may fire.
///
@@ -866,6 +940,46 @@ fn pet_combat_flag(store: &ObjectStore, self_guid: Option<u64>) -> Option<bool>
.then(|| store.0.unit_flags() & UNIT_FLAG_PET_IN_COMBAT != 0)
}
/// `0x5ee5a0` — **"the unit I am directly possessing"**, and the gate that decides whether an
/// ATTACK press latches anything at all.
///
/// This is the fact the pet bar was missing, and it is the answer to "why can the reference's
/// Attack button never stay lit". `0x4bd420` calls this on the *player* and compares the result
/// with the bar's own unit; only on a match does `0x4bd42e` raise `[0xb714b0]`. And the function
/// is not "resolve my pet" despite the shape — read at the bytes it is four tests in a row:
///
/// ```text
/// 0x5ee5bc [player + 0x1c70] & 1 ; "I am driving something that is not me" — else null
/// 0x5ee5d5 the passed unit IS the local player ; else guid 0
/// 0x5ee5e9 [[player + 0xe68] + 0x830] ; the guid I am driving -> object lookup (type 8)
/// 0x5ee626 [fields + 0xa3] & 1 ; UNIT_FLAG_POSSESSED on that unit — else null
/// 0x5ee62f charmedBy (else createdBy) == me ; else null
/// ```
///
/// `[player + 0x1c70]`'s bit 0 is set and cleared by the control-transfer handler `0x5ee2xx`
/// against the active-mover guid, firing event `0x27a` with a bool — the client's own
/// "control lost / control gained". So the whole thing means *possession*: Mind Control, Eye of
/// Kilrogg, Eyes of the Beast. vmangos agrees from the other side — `UNIT_FLAG_POSSESSED` is
/// written in exactly three places (`Player::SummonPossessedMinion`, `SPELL_AURA_MOD_POSSESS`,
/// `Player::ModPossessPet`), each of them paired with `SetMover` + `UpdateControl`, and **never**
/// for an ordinary hunter or warlock pet.
///
/// Which is why `PetActionButton_OnClick`'s `IsPetAttackActive` fork and `PetStopAttack` are dead
/// code for a hunter, the way `PetActionButton_StartFlash` is dead code for everyone: the shipped
/// `PetActionBarFrame.lua` is written for both bars and a hunter only ever takes one branch.
///
/// **What benilla checks, and the gap:** the flag and the ownership, which are the two halves that
/// live in the descriptor we already stream. The active-mover halves (`+0x1c70`, the driven guid)
/// need client-control state benilla does not model yet; they only ever *narrow* this further, and
/// the server sets the flag and the mover together, so the flag standing in for both is faithful
/// until possession itself is built.
fn possessing(store: Option<&ObjectStore>, self_guid: Option<u64>) -> bool {
store.is_some_and(|s| {
s.0.unit_flags() & UNIT_FLAG_POSSESSED != 0
&& s.0.unit_owner(benilla_protocol::OwnerFallback::CreatedBy) == self_guid
})
}
/// Is this press the ATTACK **order** — the one slot word that runs a validator before it sends?
///
/// Type 7 action 2 and nothing else. The reference's type-7 arm branches exactly twice
@@ -889,28 +1003,48 @@ fn is_attack_order(entry: PetActionEntry) -> bool {
/// lights the button where the real client would not"* — and it is deliberately the one function
/// that both decides and records, because 0998's bug was invisible to thirteen tests that checked
/// the read side and the write side separately and never their join.
fn commit_press(bar: &mut PetBar, entry: PetActionEntry, refused: bool) -> bool {
///
/// `possessing` is [`possessing`]'s answer, threaded rather than re-derived because it is the
/// *second* gate on the same press and it belongs beside the first: `0x4bd420` runs immediately
/// after `0x4bd40d`'s validator, and unlike it, failing costs only the latch — `0x4bd427 jne
/// 0x4bd444` lands on the **send**, so the order still goes out. That asymmetry is the whole shape
/// of the Attack button: the pet is always dispatched, the button never lights for it.
fn commit_press(bar: &mut PetBar, entry: PetActionEntry, refused: bool, possessing: bool) -> bool {
if refused {
return false;
}
latch_press(bar, entry);
latch_press(bar, entry, possessing);
true
}
fn latch_press(bar: &mut PetBar, entry: PetActionEntry) {
fn latch_press(bar: &mut PetBar, entry: PetActionEntry, possessing: bool) {
let action = entry.action();
match entry.kind() {
benilla_protocol::messages::PET_ACT_COMMAND
if action == PET_COMMAND_STAY || action == PET_COMMAND_FOLLOW =>
{
// `0x4bc960` — the write, then `ecx = 0x161; jmp SignalEvent`. Both halves, and the
// signal is unconditional: writing the SAME command still repaints the bar, which is
// the only thing that puts back the light `OnClick`'s `SetChecked(0)` just took off.
bar.spells.state = (bar.spells.state & 0x0800_00FF) | (action << 8);
bar.bar_signals = bar.bar_signals.wrapping_add(1);
}
benilla_protocol::messages::PET_ACT_COMMAND if action == PET_COMMAND_ATTACK => {
benilla_protocol::messages::PET_ACT_COMMAND
if action == PET_COMMAND_ATTACK && possessing =>
{
// `0x4bd42e` signals too (`mov ecx,0x161` immediately before the write), and it is the
// one signal a pet bar never gets: the possession gate above it is what skips both.
bar.attacking = true;
bar.bar_signals = bar.bar_signals.wrapping_add(1);
}
benilla_protocol::messages::PET_ACT_REACTION => {
// `0x4bc940`, the same shape one byte-mask over.
bar.spells.state = (bar.spells.state & 0xFFFF_FF00) | action;
bar.bar_signals = bar.bar_signals.wrapping_add(1);
}
// DISMISS, spells and every unknown type reach `0x4bd444` without signalling. A spell's
// button can be checked (its active-aura ring), and it comes back the honest way: the
// pet's aura field moves and the feed sees a real change.
_ => {}
}
}
@@ -1251,7 +1385,7 @@ mod tests {
};
bar.spells.state |= PET_STATE_BAR_DISABLED;
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND));
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND), false);
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert_eq!(bar.spells.react_state(), PET_REACT_DEFENSIVE, "untouched");
assert!(
@@ -1259,16 +1393,24 @@ mod tests {
"the disabled bit is the SERVER's — a command press must preserve it"
);
latch_press(&mut bar, packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION));
latch_press(
&mut bar,
packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION),
false,
);
assert_eq!(bar.spells.react_state(), PET_REACT_AGGRESSIVE);
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert!(bar.spells.bar_disabled());
latch_press(&mut bar, packed(3010, PET_ACT_DISABLED));
latch_press(&mut bar, packed(3010, PET_ACT_DISABLED), false);
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert_eq!(bar.spells.react_state(), PET_REACT_AGGRESSIVE);
latch_press(&mut bar, packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND));
latch_press(
&mut bar,
packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND),
false,
);
assert_eq!(
bar.spells.command_state() & 0xFF,
PET_COMMAND_STAY,
@@ -1276,33 +1418,145 @@ mod tests {
);
}
/// ATTACK is the one command press that also raises the attack latch — the flag that lights
/// the button and turns its next press into a call-off.
/// **The director's report: clicking a lit Follow or Passive un-toggles it.**
///
/// The CheckButton flips itself before `OnClick` runs and the reference's first line is
/// `this:SetChecked(0)`, so every press starts by taking the light off. `0x4bc940`/`0x4bc960`
/// put it back by signalling `PET_BAR_UPDATE` **unconditionally** — no old-vs-new compare — so
/// pressing the mode the pet is already in still repaints. Our feed dedups on what it pushed,
/// so without a counter in that key the light had no way back.
///
/// A press that the reference does NOT signal must not bump: DISMISS and a spell reach
/// `0x4bd444` straight, and a hunter's ATTACK skips `0x4bd429` with the latch it never raises.
#[test]
fn an_attack_press_raises_the_latch_and_dismiss_does_not() {
fn every_press_the_reference_signals_forces_a_repaint() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let signals = |b: &PetBar| b.bar_signals;
// Pressing the mode the pet is ALREADY in: nothing about the state moves, and the signal
// still fires. This is the whole bug.
let before = signals(&bar);
latch_press(&mut bar, packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND), false);
assert_eq!(bar.spells.command_state(), PET_COMMAND_FOLLOW, "unmoved");
assert_ne!(signals(&bar), before, "and it repaints anyway");
let before = signals(&bar);
latch_press(
&mut bar,
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
false,
);
assert_eq!(bar.spells.react_state(), PET_REACT_DEFENSIVE, "unmoved");
assert_ne!(signals(&bar), before, "same for the reaction side");
// The presses the reference leaves silent.
let before = signals(&bar);
latch_press(
&mut bar,
packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND),
false,
);
latch_press(&mut bar, packed(3010, PET_ACT_ENABLED), false);
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND), false);
assert_eq!(
signals(&bar),
before,
"Dismiss, a spell and a pet's Attack all reach 0x4bd444 without signalling"
);
// A POSSESSED unit's Attack does signal — `0x4bd429`, one instruction before the latch.
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND), true);
assert_ne!(signals(&bar), before);
}
/// ATTACK is the one command press that can raise the attack latch — **and only while we are
/// possessing the unit** (`0x4bd420`). On an ordinary pet bar the arm is unreachable, which is
/// the whole reason its button never checks.
#[test]
fn only_a_possessed_units_attack_press_raises_the_latch() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(!bar.attacking);
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND));
assert!(bar.attacking);
// A hunter's pet: the press goes out (the caller sends regardless) and latches nothing.
latch_press(&mut bar, attack, false);
assert!(
!bar.attacking,
"an ordinary pet can never raise the latch, so its button can never light"
);
latch_press(&mut bar, attack, true);
assert!(bar.attacking, "a possessed unit can");
bar.attacking = false;
latch_press(&mut bar, packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND));
latch_press(&mut bar, packed(PET_REACT_PASSIVE, PET_ACT_REACTION));
latch_press(&mut bar, packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND), true);
latch_press(&mut bar, packed(PET_REACT_PASSIVE, PET_ACT_REACTION), true);
assert!(!bar.attacking, "only ATTACK raises it");
}
/// The regression the director caught by eye: the Attack button behaving as a sticky toggle.
/// `0x5ee5a0` itself — the compare that decides all of the above.
///
/// ATTACK is an ORDER, not a mode. Latching it into the command byte — which the binary's
/// `action <= 1` gate forbids — lit it permanently, put Follow and Stay out, and left it lit
/// through the very `PetStopAttack` whose whole job is to put it out. The press-then-call-off
/// round trip is driven here end to end, because each half alone looks fine.
/// This is the fact the pet bar was missing: the reference is not asking "is this my pet", it
/// is asking "is this the unit I am *driving*". A hunter pet answers no on the flag, so no
/// hunter press ever reaches the latch.
#[test]
fn an_attack_order_never_becomes_the_standing_command() {
fn the_latch_gate_is_possession_not_ownership() {
const FLAGS: u16 = 46;
const CHARMEDBY: u16 = 10;
const CREATEDBY: u16 = 14;
let unit =
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
let me = Some(0x77u64);
// An ordinary hunter pet: ours, in combat, not possessed. The answer is no.
let hunter_pet = unit(&[
(CREATEDBY, 0x77),
(CREATEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert!(!possessing(Some(&hunter_pet), me));
// The same pet under Eyes of the Beast — possessed, and now the latch arm is live.
let driven = unit(&[
(CREATEDBY, 0x77),
(CREATEDBY + 1, 0),
(FLAGS, UNIT_FLAG_POSSESSED),
]);
assert!(possessing(Some(&driven), me));
// A mind-controlled mob: CHARMEDBY is the primary leg, so it needs no CREATEDBY at all.
let charmed = unit(&[(CHARMEDBY, 0x77), (CHARMEDBY + 1, 0), (FLAGS, 0x0100_0000)]);
assert!(possessing(Some(&charmed), me));
// Somebody ELSE's possessed unit is not ours to latch for.
let theirs = unit(&[
(CHARMEDBY, 0x99),
(CHARMEDBY + 1, 0),
(FLAGS, UNIT_FLAG_POSSESSED),
]);
assert!(!possessing(Some(&theirs), me));
// No descriptor is no possession — the honest reading of missing data, and the same
// posture the rest of the bar takes.
assert!(!possessing(None, me));
}
/// **The director's report, twice over: the Attack button is never a toggle.**
///
/// Reported by eye against the reference client — *"there is no way in the ref client for the
/// attack button to stay lit up/toggled like the follow or stay… it's simply a button to send
/// the pet"* — and the first fix was wrong because it took the latch's *lifetime* to be the
/// question. It is not: the latch never rises at all on a pet bar, because `0x4bd420` gates it
/// on possession. This drives a whole pet's worth of ATTACK presses and asserts the button
/// stays dark through every one.
#[test]
fn a_pets_attack_button_never_lights() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
@@ -1310,7 +1564,18 @@ mod tests {
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let follow = packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND);
latch_press(&mut bar, attack);
for _ in 0..3 {
assert!(
commit_press(&mut bar, attack, false, false),
"the order goes"
);
let lit = view(attack, &bar.spells, None, None, bar.attacking);
assert!(!lit.active, "and the button does not light — ever");
assert!(
!lit.attack_active,
"so the next press orders again, never calls off"
);
}
assert_eq!(
bar.spells.command_state(),
PET_COMMAND_FOLLOW,
@@ -1318,28 +1583,42 @@ mod tests {
);
assert!(
view(follow, &bar.spells, None, None, bar.attacking).active,
"so Follow keeps its light while the pet is on something"
);
assert!(view(attack, &bar.spells, None, None, bar.attacking).attack_active);
// The call-off: `PetStopAttack` clears the latch, and the latch was the ONLY thing lighting
// the button — so it goes out, which is what a stuck command byte used to prevent.
bar.attacking = false;
let called_off = view(attack, &bar.spells, None, None, bar.attacking);
assert!(
!called_off.active,
"the Attack button goes out with the latch"
);
assert!(
!called_off.attack_active,
"and its next press orders, not calls off"
"and Follow keeps the light that is actually a mode's"
);
// Stay is still reachable as a mode — the command byte was never hijacked.
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND));
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND), false);
assert_eq!(bar.spells.command_state(), PET_COMMAND_STAY);
}
/// The **possess** bar is the other half of the same button, and it does light — which is why
/// the `isActive` clause, `IsPetAttackActive` and `PetStopAttack` are all still modelled rather
/// than deleted as dead weight.
#[test]
fn a_possessed_units_attack_button_does_light() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let follow = packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND);
assert!(commit_press(&mut bar, attack, false, true));
assert!(view(attack, &bar.spells, None, None, bar.attacking).active);
assert!(view(attack, &bar.spells, None, None, bar.attacking).attack_active);
assert!(
view(follow, &bar.spells, None, None, bar.attacking).active,
"the latch is not the command byte — Follow keeps its own light"
);
// The call-off: `PetStopAttack` clears the latch, and the latch was the ONLY thing lighting
// the button — so it goes out.
bar.attacking = false;
let called_off = view(attack, &bar.spells, None, None, bar.attacking);
assert!(!called_off.active);
assert!(!called_off.attack_active);
}
/// **A refused ATTACK costs everything** — no packet, no latch, no light. The reference's veto
/// jumps to the function epilogue rather than the shared send (`0x4bd414 je 0x4bd4c6`), so a
/// pet that is dead, stunned, feared, confused, pacified or charmed away simply does not take
@@ -1355,13 +1634,13 @@ mod tests {
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(
!commit_press(&mut bar, attack, true),
!commit_press(&mut bar, attack, true, true),
"a vetoed order never reaches the wire"
);
assert!(!bar.attacking, "and never raises the latch");
assert!(
!view(attack, &bar.spells, None, None, bar.attacking).active,
"so the button stays dark — the bug the director saw was it lighting anyway"
"so the button stays dark, even on the one bar where it could light"
);
assert_eq!(
bar.spells.command_state(),
@@ -1369,8 +1648,9 @@ mod tests {
"a refusal cannot move the standing command either"
);
// The same press with the gate clear is the ordinary attack, unchanged.
assert!(commit_press(&mut bar, attack, false));
// The same press with the gate clear is the ordinary attack, unchanged. The two gates are
// NOT symmetric: this one costs the packet, possession costs only the latch.
assert!(commit_press(&mut bar, attack, false, true));
assert!(bar.attacking);
assert!(view(attack, &bar.spells, None, None, bar.attacking).active);
}
@@ -329,6 +329,39 @@ fn shipped_stance_bar_drives_end_to_end() {
"clicking the active stance must not untoggle its checked ring"
);
// RIGHT-CLICK IS DEAD ON THIS BAR — no flash, no cast — and that is the reference (decisions
// 1023 + 1030). `ShapeshiftButtonTemplate` inherits `ActionButtonTemplate` and then overrides
// <OnLoad> with a body that only scales the cooldown, dropping the
// `RegisterForClicks("LeftButtonUp","RightButtonUp")` that `ActionButton_OnLoad` gives every
// other action-style button (ref ActionButton.lua:109). The default {LeftButtonUp} stands, and
// `0x77924b` gates the PushedTexture on that same mask. 1027 diverged and 1030 reverted it, so
// this asserts the quirk on purpose: the left press below proves the flash works at all here.
let depressed = |s: &UiScript| {
s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Quickslot-Depress"))
})
};
s.mouse_move(56.0, 116.0);
s.mouse_button(56.0, 116.0, "RightButton", true);
assert!(
!depressed(&s),
"a right-press must NOT flash — unregistered"
);
s.mouse_button(56.0, 116.0, "RightButton", false);
assert!(
s.take_shapeshift_casts().is_empty(),
"and must not cast — the ref never routes a right-click here at all"
);
s.mouse_button(56.0, 116.0, "LeftButton", true);
assert!(depressed(&s), "the LEFT press does flash — the mask has it");
s.mouse_button(56.0, 116.0, "LeftButton", false);
let _ = s.take_shapeshift_casts();
assert!(
s.eval::<bool>("return BenillaShapeshiftButton1:GetChecked()")
.unwrap(),
"and the active form stays lit through either"
);
// An emptied push hides the whole frame (the formless class path, live: shapeshift unlearned).
s.set_shapeshift_forms(vec![]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
@@ -263,6 +263,59 @@ fn clicks_route_through_the_attack_toggle_fork() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A click takes the ring off; the repaint puts it back** — the contract decision 1021's signal
/// count depends on, pinned here so it cannot quietly rot.
///
/// `PetActionButton_OnClick`'s first line is `this:SetChecked(0)` and it runs for every click, so
/// the ring always comes off. What puts it back is `PET_BAR_UPDATE` — and the repaint that answers
/// it must re-derive `SetChecked` from `isActive` per slot, **not** diff the views, or a press that
/// changed no state would have no way home.
///
/// The reference signals that repaint from the state writes (`0x4bc940`/`0x4bc960`) and **not**
/// from a `TogglePetAutocast` it refuses (`0x4bcbf7` — a token is not autocastable, i.e. every
/// right-click on Follow, Stay or a reaction). So on a right-click the ring genuinely stays off
/// until something else repaints the bar. That is the reference's own quirk, checked there by the
/// director; decision 1027 diverged from it and 1030 put it back.
#[test]
fn a_click_drops_the_ring_and_the_repaint_restores_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
let checked = |s: &UiScript| {
s.eval::<bool>("return BenillaPetActionButton1:GetChecked()")
.unwrap()
};
// Button 1 (Attack, lit) spans x[72,102] y[56,86] ⇒ centre (87,71). Both mouse buttons take
// the ring off — the CheckButton toggles itself, then `SetChecked(0)` lands it at 0 either way.
for button in ["RightButton", "LeftButton"] {
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
assert!(checked(&s), "{button}: the Attack token starts lit");
s.mouse_button(87.0, 71.0, button, true);
s.mouse_button(87.0, 71.0, button, false);
assert!(
!checked(&s),
"{button}: the ref's own SetChecked(0), reproduced"
);
// A repaint carrying the very SAME views restores it — the property the signal count buys.
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(
checked(&s),
"{button}: an unchanged repaint must re-light, not diff to a no-op"
);
}
let _ = s.take_pet_actions();
let _ = s.take_pet_stop_attacks();
let _ = s.take_pet_autocast_toggles();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A DISABLED bar still draws — every icon desaturated, nothing hidden. That pair
/// (`PetHasActionBar` true, `GetPetActionsUsable` false) is what a feared or mind-controlled pet
/// looks like, and collapsing it to "hide the bar" would lose the state entirely.
@@ -54,6 +54,37 @@ fn the_whole_shipped_manifest_loads_without_errors() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Loading the UI makes no sound.** Materializing the shipped manifest is bookkeeping — nothing
/// has opened, so nothing may be heard (decision 1033).
///
/// The defect this pins: a dropdown's `OnLoad` calls `UIDropDownMenu_Initialize`, which —
/// faithfully, ref `UIDropDownMenu.lua` l.49-52 — *calls the init function immediately*. For the
/// unit popups that init function reaches `UnitPopup_ShowMenu`, whose last line is
/// `PlaySound("igMainMenuOpen")`. The ref never gets there at load: `UnitPopup_HideButtons` leaves
/// nothing but CANCEL shown for a unit that does not exist, tripping the "only one item, don't show
/// the menu" early-out. Ours was missing that hide for FOLLOW and INSPECT (ref l.304-307/316-319),
/// so all four party dropdowns rang on startup — four copies of the menu-open tack stacked in one
/// frame, on the login screen.
///
/// Deliberately asserted over the WHOLE manifest rather than the popup: any future window that
/// plays a sound from a load-time handler is the same bug, and this is where it gets caught. Sound
/// is on by default now (decision 1026), so a load-time sound is something the director hears on
/// every single launch.
#[test]
fn loading_the_shipped_ui_queues_no_sounds() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
assert_eq!(
s.take_sounds(),
vec![],
"loading the UI played a sound — a load-time handler is ringing; see \
UnitPopup_HideButtons / UIDropDownMenu_Initialize (decision 1033)"
);
}
/// **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).
///
@@ -1197,3 +1197,90 @@ fn the_party_art_paints_over_the_bars() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **What a feigning hunter looks like on the frames** (decision 1022) — the end of the chain the
/// snapshot starts: `UNIT_DYNFLAG_DEAD` zeroes `UnitHealth`/`UnitMana` while the maxima stay real
/// (`UnitHealth 0x5174d0` gates, `UnitHealthMax 0x5175b0` does not), so both bars run **empty over
/// a full-size track** rather than collapsing to a 0/0 nothing, and the target frame's DEAD text
/// lights on the same `UnitHealth(unit) <= 0` test a corpse trips.
#[test]
fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let hunter = |health: u32, power: u32, dead: bool| {
Some(UnitState {
exists: true,
name: Some("Nazriel".into()),
health,
max_health: 1500,
level: 60,
power_type: 0,
power,
max_power: 900,
dead,
reaction: 2, // hostile — the frame we would be watching him through
..UnitState::default()
})
};
s.set_unit("target", hunter(1200, 300, false));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let alive: bool = s
.eval(
r#"
local hb, pb = BenillaTargetFrameHealthBar, BenillaTargetFramePowerBar
return hb:GetValue() == 1200 and pb:GetValue() == 300
and BenillaTargetFrameTextureFrameDeadText:GetText() == ""
"#,
)
.unwrap();
assert!(alive, "the control: a live hunter reads live");
// He feigns. Only the flag moved on the wire — the snapshot turns it into these three.
s.set_unit("target", hunter(0, 0, true));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
let (hp, hmax, mana, mmax): (f64, f64, f64, f64) = (
s.eval("return BenillaTargetFrameHealthBar:GetValue()")
.unwrap(),
s.eval("local _, m = BenillaTargetFrameHealthBar:GetMinMaxValues() return m")
.unwrap(),
s.eval("return BenillaTargetFramePowerBar:GetValue()")
.unwrap(),
s.eval("local _, m = BenillaTargetFramePowerBar:GetMinMaxValues() return m")
.unwrap(),
);
assert_eq!((hp, hmax), (0.0, 1500.0), "empty health bar, real track");
assert_eq!((mana, mmax), (0.0, 900.0), "empty mana bar, real track");
assert!(
s.eval::<bool>("return BenillaTargetFramePowerBar:IsVisible()")
.unwrap(),
"the mana bar empties, it does not disappear — UnitManaMax 0x5177e0 is ungated"
);
assert_eq!(
s.eval::<String>("return BenillaTargetFrameTextureFrameDeadText:GetText()")
.unwrap(),
"DEAD",
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
);
assert!(
s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap(),
"UnitIsDead 0x517ac0's dynflag leg reaches the API too"
);
// He stands back up: the flag clears, and nothing about the body needed restoring.
s.set_unit("target", hunter(1200, 300, false));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
let up: bool = s
.eval(
r#"
return BenillaTargetFrameHealthBar:GetValue() == 1200
and BenillaTargetFramePowerBar:GetValue() == 300
and BenillaTargetFrameTextureFrameDeadText:GetText() == ""
and not UnitIsDead("target")
"#,
)
.unwrap();
assert!(up, "the feign ends and the frame reads live again");
}
+81 -4
View File
@@ -334,13 +334,24 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8)
UnitState {
exists: true,
name,
health: store.0.unit_health().unwrap_or(0),
// The UI-facing health/power getters, not the raw fields (decision 1022): a unit carrying
// `UNIT_DYNFLAG_DEAD` — a feigning hunter — answers 0 to `UnitHealth 0x5174d0` and
// `UnitMana 0x517670`, while the *max* getters stay ungated, so its bars read 0/max (empty)
// for itself and for everyone watching. The zeroes ride the ordinary per-field diff in
// [`fire_transitions`], which fires `UNIT_HEALTH` + the power event on the flag's edge —
// exactly the pair the reference's `UNIT_DYNAMIC_FLAGS` watcher fires there
// (`0x6004c5`/`0x6004f0`, event `0x10` and `0x11 + powerType`). The power pair also carries
// the raw→display divide the same two getters do (decision 1034) — rage rides the wire ×10
// and pet happiness ×1000, so these are the numbers the reference shows, not the wire's.
health: store.0.unit_shown_health().unwrap_or(0),
max_health: store.0.unit_max_health().unwrap_or(0),
level: store.0.unit_level().unwrap_or(0),
power_type,
power: store.0.unit_power(power_type).unwrap_or(0),
max_power: store.0.unit_max_power(power_type).unwrap_or(0),
dead: store.0.unit_is_dead(),
power: store.0.unit_shown_power(power_type).unwrap_or(0),
max_power: store.0.unit_shown_max_power(power_type).unwrap_or(0),
// `UnitIsDead 0x517ac0` — health ≤ 0 **or** the dead-looking flag, so a feigning unit is
// dead to the API, to the target frame's DEAD text and to the greyed portrait alike.
dead: store.0.unit_reads_dead(),
// The released-ghost predicate (decision 0308 §1): PLAYER_FLAGS bit 0x10 — a ghost's
// health is 1, so `dead` above is false for it. Zero/absent on creatures.
ghost: store.0.player_is_ghost(),
@@ -806,6 +817,72 @@ mod tests {
);
}
/// **A feigning hunter's frame reads as a corpse's** (decision 1022) — the symptom the whole
/// change exists for: the wire says nothing but `UNIT_DYNFLAG_DEAD`, and the snapshot has to
/// turn that into empty bars against a real maximum plus `UnitIsDead`. Asserted through
/// `snapshot` rather than the field getters (which have their own byte-law test) because what
/// can regress here is the *wiring* — a future edit reaching for `unit_health` again.
#[test]
fn a_feigning_unit_snapshots_empty_bars_over_a_real_maximum() {
use benilla_protocol::messages::ObjectFields;
/// `UNIT_FIELD_HEALTH` / `MAXHEALTH` / `POWER1` / `MAXPOWER1` / `DYNAMIC_FLAGS`.
const HEALTH: u16 = 22;
const POWER1: u16 = 23;
const MAXHEALTH: u16 = 28;
const MAXPOWER1: u16 = 29;
const DYNFLAGS: u16 = 143;
let vitals = [
(HEALTH, 1200),
(MAXHEALTH, 1500),
(POWER1, 300),
(MAXPOWER1, 900),
];
let alive = snapshot(
&ObjectStore(ObjectFields::from_pairs(&vitals)),
Some("Hunter".into()),
0,
);
assert_eq!((alive.health, alive.max_health), (1200, 1500));
assert_eq!((alive.power, alive.max_power), (300, 900));
assert!(!alive.dead);
let feigning = snapshot(
&ObjectStore(ObjectFields::from_pairs(
&[vitals.as_slice(), &[(DYNFLAGS, 0x20)]].concat(),
)),
Some("Hunter".into()),
0,
);
assert_eq!(
(feigning.health, feigning.max_health),
(0, 1500),
"UnitHealth 0x5174d0 zeroes, UnitHealthMax 0x5175b0 does not — an EMPTY bar, not a gone one"
);
assert_eq!(
(feigning.power, feigning.max_power),
(0, 900),
"UnitMana 0x517670 zeroes, UnitManaMax 0x5177e0 does not"
);
assert!(feigning.dead, "UnitIsDead 0x517ac0's dynflag leg");
assert!(
!feigning.ghost,
"feign is not a ghost — PLAYER_FLAGS is clear"
);
// …and the flag moves the two fields [`fire_transitions`] diffs, so the edge announces
// itself as `UNIT_HEALTH` + the power event — the very pair the reference's own dynamic-
// flags watcher fires there (`0x6004c5`, `0x6004f0`). Nothing else about the unit moved,
// which is why routing the flag through the getters is enough: no extra watcher needed.
assert_ne!(alive.health, feigning.health);
assert_ne!(alive.power, feigning.power);
assert_eq!(
(alive.max_health, alive.max_power, alive.level),
(feigning.max_health, feigning.max_power, feigning.level),
);
}
/// The rank getter's two gates (`0x605620`, decision 0782) as they reach a snapshot. The pet
/// gate is the interesting half: a charmed or enslaved elite reports rank 0, so it loses its
/// dragon border AND its ELITE tooltip word AND (at rank 3) its world-boss skull together.
+10 -1
View File
@@ -494,7 +494,8 @@ fn drive_vplates(
// A DEAD unit shows no plate — the per-tick gate `0x60f600`'s FIRST, unconditional
// test (§8 Q2, byte-confirmed): signed `UNIT_FIELD_HEALTH ≤ 0`, absent = 0 (the
// zero-init descriptor). A poll, not a death callback; the lootable bit is never
// consulted; feign keeps health > 0 and stays plated.
// consulted. Feign death keeps health > 0, so it clears THIS leg — the gate that takes a
// feigning body's plate away is the hostile-only one below (decision 1022).
if store.is_none_or(|s| s.0.unit_health().unwrap_or(0) == 0) {
continue;
}
@@ -522,6 +523,14 @@ fn drive_vplates(
if friendly && !mode.friends || !friendly && !mode.enemies {
continue;
}
// The gate's dead-looking leg, which sits on the HOSTILE side of that same split
// (`0x60f72f test al,al; jne` → friendly goes to the creature-type check `0x60f750`;
// hostile falls into `0x60f733 shr eax,5; test al,1` → hide). So a feigning hunter's
// plate vanishes for the enemies he is hiding from, which is the point of the spell,
// while a friendly one keeps his (decision 1022).
if !friendly && store.is_some_and(|s| s.0.unit_reads_dead()) {
continue;
}
// Project the plate point: the overhead anchor + 2/3 yd, per frame, no smoothing. A
// point behind the camera draws nothing (the plate has no clamp — offscreen is gone).
let anchor = overhead_anchor(
@@ -0,0 +1,387 @@
//! Live probe (decision 1029, B215): **what an addon broadcast actually looks like on the wire.**
//!
//! 1.12.1 has no addon opcode — `SendAddonMessage` rides the ordinary chat lanes and the sentinel
//! `language = LANG_ADDON` (`0xFFFFFFFF`) is the *only* thing separating "a hunter addon telling
//! the party its version" from "someone talking". benilla rendered whatever arrived, so a partied
//! real-client player's Quiver ping printed as `[Party] [Soreen]: Quiver VERSION:3.1.4`. The gate
//! that fixes it keys on that one field, so that one field had to be proved end-to-end against a
//! live vmangos rather than argued from folk memory of the lane.
//!
//! What it asserts, on the reported lane (`CHAT_MSG_PARTY`) and on the other lane addon traffic
//! commonly uses (`CHAT_MSG_CHANNEL`):
//!
//! - an addon send comes back out of the server with `language == LANG_ADDON` **intact** (the
//! relay does not normalise it away — `BuildChatPacket` is handed `Language(packet.lang)`
//! verbatim, `Handlers/ChatHandler.cpp:488`);
//! - its `chat_type` is the **ordinary lane's** type byte, not some addon-specific value: there is
//! nothing in the type field to filter on;
//! - the `prefix` TAB `payload` text survives byte-for-byte (addon chat skips `SanitizeChatMessage`
//! entirely, `ChatHandler.cpp:49`, so the tab is not stripped);
//! - the control — a plain line on the same lane — comes back with the character's real tongue, so
//! a language-keyed gate cannot swallow speech.
//!
//! Run: `cargo run -p benilla-protocol --example addon_chat_probe -- probeN pprobeN [probeM
//! pprobeM] [host]`. With one account it runs the CHANNEL case alone (the server echoes a channel
//! line back to its own sender, so one session is a complete round trip). With two it also runs
//! the PARTY case, which needs a real group: the first account invites the second, the second
//! accepts, and the addon line goes out over `/p`. **Use `probe8`/`probe9` for the second
//! identity** — those two accounts sit above `WT_MAX_SLOTS`, so no live session's slot owns them
//! (decision 0530's slot-keyed rule).
//!
//! Server prerequisites, both checked by the probe's own output rather than assumed:
//! `AddonChannel = 1` (off ⇒ the server silently drops every addon send) and
//! `AllowTwoSide.Interaction.Channel = 0` (on ⇒ `Channel::Say` rewrites the language to
//! `LANG_UNIVERSAL` and the CHANNEL case would report a false negative).
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use benilla_protocol::messages::{
channel_notice, CHAT_MSG_CHANNEL, CHAT_MSG_PARTY, CHAT_TYPE_CHANNEL, CHAT_TYPE_PARTY,
LANGUAGE_ADDON,
};
use benilla_protocol::{ServerPacket, WorldSession, WORLD_PORT};
/// The channel the CHANNEL case talks over. A custom name (not General/Trade) so the probe never
/// touches a real channel, and so its own membership is the only membership.
const PROBE_CHANNEL: &str = "benillaaddonprobe";
/// The addon payload under test — the reported one, verbatim: prefix `Quiver`, TAB, the version
/// string a real Quiver 3.1.4 broadcasts.
const ADDON_PREFIX: &str = "Quiver";
const ADDON_BODY: &str = "VERSION:3.1.4";
/// The slot's probe character: `probe4` → `Probefour` (decision 0530's
/// `probeN`/`pprobeN`/`Probe<N-spelled>` convention).
fn probe_char_name(user: &str) -> Option<String> {
let n: usize = user.strip_prefix("probe")?.parse().ok()?;
let spelled = [
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
]
.get(n)?;
Some(format!("Probe{spelled}"))
}
fn login(host: &str, user: &str, pass: &str) -> Result<(WorldSession, String)> {
let name =
probe_char_name(user).context("account must be a slot-keyed probeN (decision 0530)")?;
let logon = benilla_protocol::logon(host, user, pass)?;
let addr = logon
.realms
.first()
.map(|r| r.address.clone())
.unwrap_or_else(|| format!("{host}:{WORLD_PORT}"));
let mut session = WorldSession::connect(&addr, user, logon.session_key)?;
let character = session
.char_enum()?
.into_iter()
.find(|c| c.name == name)
.with_context(|| format!("{user} has no character {name}"))?;
session.player_login(character.guid)?;
session.set_active_mover(character.guid)?;
session.set_read_timeout(Some(Duration::from_millis(200)))?;
// Wait for `SMSG_LOGIN_VERIFY_WORLD` before sending anything else. Every `STATUS_LOGGEDIN`
// opcode is dropped **silently** until `_player->IsInWorld()` (vmangos
// `WorldSession::ProcessPackets`, `Server/WorldSession.cpp:568-577`: no reply, no log, just
// `break`) — a channel join issued into the login flood vanishes, and the only symptom is a
// `NOT_MEMBER` on the next say, which reads like a permissions problem rather than a race.
if !wait_for(&mut session, 30, |p| {
matches!(p, ServerPacket::LoginVerifyWorld { .. })
}) {
bail!(
"{name} never got SMSG_LOGIN_VERIFY_WORLD — not in world, every send would be dropped"
);
}
// Start ungrouped, always. `CMSG_GROUP_DISBAND` is a no-op when we have no group (vmangos
// `HandleGroupDisbandOpcode` returns immediately), so this is free — and without it a run that
// died before its teardown leaves the character in a group forever, after which every later
// invite is answered `SMSG_PARTY_COMMAND_RESULT` instead of forming a party. That is a probe
// that fails for a reason having nothing to do with what it tests.
session.group_disband()?;
println!("probe: logged in {name} ({user}) — in world, ungrouped");
Ok((session, name))
}
/// A read timeout on a quiet socket — the normal case, and the one recv error that is not news.
fn is_timeout(e: &anyhow::Error) -> bool {
e.chain().any(|c| {
c.downcast_ref::<std::io::Error>().is_some_and(|io| {
matches!(
io.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
})
})
}
/// Drain until `done` says the thing we were waiting for arrived, or `secs` elapse. Every step of
/// the setup waits on the **server's own acknowledgement** rather than a sleep: the login flood
/// runs for seconds and a channel join issued into it is answered `NOT_MEMBER` when the say lands
/// first — a sleep long enough to hide that is a sleep that will be too short on a loaded server.
fn wait_for(
session: &mut WorldSession,
secs: u64,
mut done: impl FnMut(&ServerPacket) -> bool,
) -> bool {
let trace = std::env::var("WOW_ADDON_PROBE_TRACE").is_ok();
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
match session.recv() {
Ok(packet) => {
if trace {
println!("probe: .. {}", packet.name());
}
if let ServerPacket::Notification { text } = &packet {
println!("probe: <- NOTIFICATION {text:?}");
}
if done(&packet) {
return true;
}
}
Err(e) if trace && !is_timeout(&e) => println!("probe: !! recv: {e:#}"),
Err(_) => {}
}
}
false
}
/// One inbound line the probe cared about.
struct Seen {
chat_type: u8,
language: u32,
text: String,
}
/// Drain `session` for up to `secs`, keeping every `SMSG_MESSAGECHAT` whose text is one of ours.
/// Read timeouts are the normal case (a quiet socket), never an error.
///
/// `WOW_ADDON_PROBE_TRACE=1` names every packet that goes past — the affordance that turns "the
/// line never arrived" into "the *join* was refused", which is the only interesting way this probe
/// fails.
fn collect(session: &mut WorldSession, secs: u64, wanted: &[&str]) -> Vec<Seen> {
let trace = std::env::var("WOW_ADDON_PROBE_TRACE").is_ok();
let mut seen = Vec::new();
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
// A recv error here is a read timeout on a quiet socket — the normal case, not news.
let Ok(packet) = session.recv() else { continue };
if trace {
println!("probe: .. {}", packet.name());
}
match packet {
ServerPacket::MessageChat(m) => {
if wanted.iter().any(|w| m.text == *w) {
println!(
"probe: <- type {:#04x} language {:#010x} text {:?}",
m.chat_type, m.language, m.text
);
seen.push(Seen {
chat_type: m.chat_type,
language: m.language,
text: m.text,
});
} else if trace {
println!(
"probe: (other chat: type {:#04x} lang {:#010x} {:?})",
m.chat_type, m.language, m.text
);
}
}
ServerPacket::Notification { text } => println!("probe: <- NOTIFICATION {text:?}"),
ServerPacket::ChannelNotify(n) => println!(
"probe: <- CHANNEL_NOTIFY notice {:#04x} on {:?}",
n.notice, n.channel
),
_ => {}
}
}
seen
}
/// Assert one lane's pair: the addon line arrives with `LANG_ADDON` on the lane's own type byte and
/// its tab intact, and the control line arrives on the same type byte with a real tongue.
fn judge(lane: &str, expect_type: u8, addon_text: &str, control_text: &str, seen: &[Seen]) -> bool {
let mut ok = true;
let mut check = |label: &str, cond: bool| {
println!(
"probe: [{}] {lane} {label}",
if cond { "PASS" } else { "FAIL" }
);
ok &= cond;
};
match seen.iter().find(|s| s.text == addon_text) {
Some(a) => {
check("addon line arrived", true);
check(
&format!("addon language is LANG_ADDON (got {:#010x})", a.language),
a.language == LANGUAGE_ADDON,
);
check(
&format!(
"addon rides the lane's own type byte {expect_type:#04x} (got {:#04x})",
a.chat_type
),
a.chat_type == expect_type,
);
check(
"addon payload keeps its TAB byte-for-byte",
a.text == addon_text && a.text.contains('\t'),
);
}
None => check("addon line arrived", false),
}
match seen.iter().find(|s| s.text == control_text) {
Some(c) => {
check("control line arrived", true);
check(
&format!(
"control speaks a real tongue, not LANG_ADDON (got {:#010x})",
c.language
),
c.language != LANGUAGE_ADDON,
);
check(
&format!(
"control rides the same type byte {expect_type:#04x} (got {:#04x})",
c.chat_type
),
c.chat_type == expect_type,
);
}
None => check("control line arrived", false),
}
ok
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let usage = "usage: addon_chat_probe -- <probeN> <pprobeN> [<probeM> <pprobeM>] [host]";
let (user_a, pass_a) = match args.as_slice() {
[a, b, ..] => (a.clone(), b.clone()),
_ => bail!("{usage}"),
};
// The optional second identity, then the optional host — told apart by the `probe` prefix.
let second = match args.get(2) {
Some(u) if u.starts_with("probe") => Some((u.clone(), args.get(3).context(usage)?.clone())),
_ => None,
};
let host = args
.get(if second.is_some() { 4 } else { 2 })
.cloned()
.unwrap_or_else(|| "localhost".into());
let addon_text = format!("{ADDON_PREFIX}\t{ADDON_BODY}");
let mut all_ok = true;
// The "be the other client in a running benilla's party" mode: instead of driving both sides,
// wait to be invited, accept, and broadcast. This is how the app-side gate is closed against
// the *symptom* — a benilla started with `WOW_PROBE_CHAT="/invite <this character>"` pulls the
// probe into its party and then has to not render what the probe says.
let await_invite = std::env::var("WOW_ADDON_PROBE_AWAIT_INVITE").is_ok();
// ── 1 · the CHANNEL lane, single session ──────────────────────────────────────────────────
// `Channel::SendToAll` (vmangos `Chat/Channel.cpp:777-785`) writes to every member INCLUDING
// the sender, so one account is a complete send→relay→receive round trip.
let (mut a, name_a) = login(&host, &user_a, &pass_a)?;
if await_invite {
println!("probe: — PARTY lane — {name_a} waits to be invited (60s)");
if !wait_for(&mut a, 60, |p| {
matches!(p, ServerPacket::GroupInvite { .. })
}) {
bail!("no SMSG_GROUP_INVITE in 60s — did the other client send `/invite {name_a}`?");
}
a.group_accept()?;
if !wait_for(&mut a, 20, |p| matches!(p, ServerPacket::GroupList { .. })) {
bail!("accepted but never got SMSG_GROUP_LIST — the group did not form");
}
println!("probe: grouped — broadcasting");
let party_control = "party control line";
a.send_addon_message(CHAT_TYPE_PARTY, None, &addon_text)?;
a.send_chat_kind(CHAT_TYPE_PARTY, None, party_control)?;
// Party chat echoes to its own sender (`Group::BroadcastPacket` takes no ignore guid), so
// even as the talker the probe still sees the relayed wire and can judge it.
let seen = collect(&mut a, 8, &[&addon_text, party_control]);
all_ok &= judge("PARTY", CHAT_MSG_PARTY, &addon_text, party_control, &seen);
a.group_disband()?;
println!("---");
if all_ok {
println!("VERDICT: PASS — broadcast sent and relayed; the other client's log is the other half");
return Ok(());
}
bail!("VERDICT: FAIL — see the per-check lines above");
}
println!("probe: — CHANNEL lane — {name_a} joins {PROBE_CHANNEL:?}");
a.join_channel(PROBE_CHANNEL, "")?;
if !wait_for(&mut a, 20, |p| {
matches!(p, ServerPacket::ChannelNotify(n)
if n.channel.eq_ignore_ascii_case(PROBE_CHANNEL) && n.notice == channel_notice::YOU_JOINED)
}) {
bail!("CHANNEL lane: no YOU_JOINED for {PROBE_CHANNEL:?} — the join never took");
}
println!("probe: joined {PROBE_CHANNEL:?}");
let chan_control = "channel control line";
a.send_addon_message(CHAT_TYPE_CHANNEL, Some(PROBE_CHANNEL), &addon_text)?;
a.send_chat_kind(CHAT_TYPE_CHANNEL, Some(PROBE_CHANNEL), chan_control)?;
let seen = collect(&mut a, 5, &[&addon_text, chan_control]);
all_ok &= judge(
"CHANNEL",
CHAT_MSG_CHANNEL,
&addon_text,
chan_control,
&seen,
);
a.leave_channel(PROBE_CHANNEL)?;
// ── 2 · the PARTY lane, the reported case — needs a real group, so a second identity ──────
match second {
None => println!(
"probe: — PARTY lane — SKIPPED (pass a second probe account to run the reported case)"
),
Some((user_b, pass_b)) => {
let (mut b, name_b) = login(&host, &user_b, &pass_b)?;
println!("probe: — PARTY lane — {name_a} invites {name_b}");
a.group_invite(&name_b)?;
if !wait_for(&mut b, 20, |p| {
matches!(p, ServerPacket::GroupInvite { .. })
}) {
bail!("PARTY lane: {name_b} never saw SMSG_GROUP_INVITE — is {name_a} already grouped?");
}
b.group_accept()?;
// The group is only real once the server pushes the roster to BOTH sides; `/p` from A
// before A's own `SMSG_GROUP_LIST` lands is dropped by `if (!group) return`.
if !wait_for(&mut b, 20, |p| matches!(p, ServerPacket::GroupList { .. })) {
bail!("PARTY lane: {name_b} accepted but never got SMSG_GROUP_LIST");
}
if !wait_for(&mut a, 20, |p| matches!(p, ServerPacket::GroupList { .. })) {
bail!("PARTY lane: {name_a} never got SMSG_GROUP_LIST — the group did not form");
}
println!("probe: {name_a} + {name_b} are grouped");
let party_control = "party control line";
a.send_addon_message(CHAT_TYPE_PARTY, None, &addon_text)?;
a.send_chat_kind(CHAT_TYPE_PARTY, None, party_control)?;
// B is the *other* client — exactly the reported situation (a partied real client's
// addon talking at us).
let seen = collect(&mut b, 5, &[&addon_text, party_control]);
all_ok &= judge("PARTY", CHAT_MSG_PARTY, &addon_text, party_control, &seen);
a.group_disband()?;
b.group_disband()?;
}
}
println!("---");
if all_ok {
println!(
"VERDICT: PASS — an addon broadcast reaches the client as an ORDINARY chat type with \
language {LANGUAGE_ADDON:#010x} and its TAB intact; the language field is the whole \
discriminator"
);
Ok(())
} else {
bail!(
"VERDICT: FAIL — see the per-check lines above. If EVERY addon line is missing, check \
`AddonChannel = 1` in mangosd.conf; if the CHANNEL addon line arrived with language \
0x00000000, `AllowTwoSide.Interaction.Channel` is on and rewrote it."
)
}
}
@@ -129,6 +129,28 @@ pub struct ChatMessage {
pub chat_tag: u8,
}
impl ChatMessage {
/// Is this line addon traffic rather than speech? (`language == LANG_ADDON`, decision 1029.)
///
/// The `language` field is the **whole** discriminator: 1.12.1 has no addon opcode and no addon
/// `ChatMsg` type, so an addon broadcast is an ordinary `SMSG_MESSAGECHAT` on whichever lane it
/// was sent over — `CHAT_MSG_PARTY` for a hunter addon's version ping, `CHAT_MSG_CHANNEL` for a
/// hidden-channel protocol — and only the sentinel language tells it apart. The chat frame must
/// drop it; rendering it is what printed `[Party] [Soreen]: Quiver VERSION:3.1.4` (B215).
///
/// The `text` of such a line is an addon payload, not a sentence: `prefix`, a TAB, then the
/// addon's own encoding. It is deliberately **not** split here — nothing consumes it until
/// benilla runs third-party addons, and the split rule belongs with the `CHAT_MSG_ADDON` fire
/// that would need it. When that lands, the rule is byte-verified and slightly
/// counter-intuitive (wow-re `system/ui/scratch/addon-chat-law.md`): the copy **starts** in the
/// prefix buffer and the **first** tab switches it to the message buffer, so text with no tab
/// at all yields `prefix = <the whole text>`, `message = ""` — not the reverse — and the event
/// still fires. Later tabs are literal payload bytes.
pub fn is_addon(&self) -> bool {
self.language == super::LANGUAGE_ADDON
}
}
/// Read the length-prefixed string shape (`u32 len` including NUL + bytes).
fn read_len_string(r: &mut &[u8]) -> io::Result<String> {
let len = read_u32_le(r)? as usize;
@@ -196,6 +196,32 @@ pub const LOGOUT_SUCCESS: u32 = 0x0;
pub const LANGUAGE_COMMON: u32 = 0x7;
pub const LANGUAGE_ORCISH: u32 = 0x1;
/// `LANG_ADDON` (VERIFIED vmangos `SharedDefines.h:270`) — **not a tongue**. 1.12.1 has no addon
/// opcode: `SendAddonMessage` rides the ordinary chat lanes and this sentinel in the `language`
/// field is the *only* thing that marks the line as addon-to-addon data rather than speech
/// (decision 1029). The real client routes it to the `CHAT_MSG_ADDON` event; it never reaches the
/// chat frame.
///
/// The server treats it as its own class throughout: exempt from the `KnowsLanguage` gate, from
/// flood control, and from message sanitizing (`SanitizeChatMessage` returns early,
/// `Handlers/ChatHandler.cpp:49`); gated instead by the `AddonChannel` config
/// (`ChatHandler.cpp:165`); and restricted by `WorldSession::IsLanguageAllowedForChatType`
/// (`ChatHandler.cpp:84`) to the group/guild/channel lanes — PARTY, RAID, RAID_LEADER,
/// RAID_WARNING, GUILD, OFFICER, BATTLEGROUND, BATTLEGROUND_LEADER, CHANNEL. Never
/// SAY/YELL/EMOTE/WHISPER, which is why addon traffic can never reach a chat bubble or a
/// `/r` target. It is also the one language the server never rewrites: the whole
/// GM/two-side/`SPELL_AURA_MOD_LANGUAGE` normalisation block lives in that check's `else`
/// (`ChatHandler.cpp:176-218`), so on the party lane a line's language is either a tongue (often
/// normalised to `Universal`) or exactly this.
///
/// **The client's own send set is narrower than the server's permission list.** VERIFIED in
/// `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md`: `SendAddonMessage`
/// (`0x49f920`) hard-whitelists **four** types at `0x49fa3f`-`0x49fa4e` — PARTY, RAID, GUILD,
/// BATTLEGROUND — and the receive side's `distribution` argument uses the same four (remap table
/// `0x49aff4`), reporting every other type as the literal `"UNKNOWN"`. So a real client accepts
/// addon traffic on lanes it will never itself send on.
pub const LANGUAGE_ADDON: u32 = 0xFFFF_FFFF;
/// The language a character speaks by default — its faction tongue. Every send must carry a
/// language the character *knows*: vmangos drops the whole message (including a `.command`
/// payload, which is intercepted downstream of the check) with a "not learned" notification
@@ -459,3 +459,121 @@ fn dynamicobject_fields_read_the_live_blizzard_capture() {
None
);
}
/// **Feign death is one bit, and the client reads it everywhere it reads health** (decision 1022).
/// `UNIT_DYNFLAG_DEAD` leaves `UNIT_FIELD_HEALTH` alone — vmangos `Unit::SetFeignDeath` only sets
/// the flag — so every predicate that keys on the raw field must stay FALSE while every one that
/// keys on how the unit *reads* must flip. The byte law being transcribed: `UnitHealth 0x5174d0`
/// and `UnitMana 0x517670` answer 0 under the flag, their `*Max` siblings (`0x5175b0`/`0x5177e0`)
/// have no such gate, and `0x605f90` is the shared reads-dead triple.
#[test]
fn feign_death_reads_dead_without_touching_the_raw_health_field() {
const MANA: u8 = 0;
let unit = |pairs: &[(u16, u32)]| ObjectFields::from_pairs(pairs);
let alive = unit(&[
(FIELD_UNIT_HEALTH, 1200),
(FIELD_UNIT_MAXHEALTH, 1500),
(FIELD_UNIT_POWER1, 300),
(FIELD_UNIT_MAXPOWER1, 900),
]);
let feigning = unit(&[
(FIELD_UNIT_HEALTH, 1200),
(FIELD_UNIT_MAXHEALTH, 1500),
(FIELD_UNIT_POWER1, 300),
(FIELD_UNIT_MAXPOWER1, 900),
(FIELD_UNIT_DYNAMIC_FLAGS, 0x20),
]);
let corpse = unit(&[(FIELD_UNIT_HEALTH, 0), (FIELD_UNIT_MAXHEALTH, 1500)]);
// The raw field predicate is untouched by the flag — the death arc, loot and the corpse never
// fire for a feign.
assert!(!alive.unit_is_dead());
assert!(!feigning.unit_is_dead(), "feign leaves HEALTH alone");
assert!(corpse.unit_is_dead());
assert_eq!(feigning.unit_health(), Some(1200), "the raw field survives");
// …and the reads-dead predicate flips for both, plus the stand-state leg.
assert!(!alive.unit_reads_dead());
assert!(feigning.unit_reads_dead(), "0x605f9d — the dynflag leg");
assert!(corpse.unit_reads_dead());
assert!(
unit(&[
(FIELD_UNIT_HEALTH, 1200),
(FIELD_UNIT_MAXHEALTH, 1500),
(FIELD_UNIT_BYTES_1, 7),
])
.unit_reads_dead(),
"0x605faa — stand state 7 (inert against vmangos, kept as the reference has it)"
);
// The UI getters: current goes to zero, the maxima do not — that is what makes the bar render
// 0/max (empty) instead of vanishing.
assert_eq!(alive.unit_shown_health(), Some(1200));
assert_eq!(feigning.unit_shown_health(), Some(0));
assert_eq!(feigning.unit_max_health(), Some(1500));
assert_eq!(alive.unit_shown_power(MANA), Some(300));
assert_eq!(feigning.unit_shown_power(MANA), Some(0));
assert_eq!(feigning.unit_max_power(MANA), Some(900));
// Out-of-range power slots stay nil under the flag, exactly as the ungated reader has them.
assert_eq!(feigning.unit_shown_power(5), None);
// An empty store is not a feigning one: no flag, no zeroing, and no invented health.
assert!(!ObjectFields::default().unit_reads_dead());
assert_eq!(ObjectFields::default().unit_shown_health(), None);
}
/// **The raw→display power divide** (decision 1034, wow-re `feign-death-dyndead.md` §3): the
/// reference's `UnitMana`/`UnitManaMax` divide by `0x6e7130`'s table at `0x86f978` —
/// `{1, 10, 1, 1, 1000}`. vmangos ships rage max **1000** and pet happiness max **1050000**
/// (`GetCreatePowers`), which through that divide are the 100 and 1050 a real client shows. The
/// RAW accessors must stay raw: the pet happiness bucket thresholds compare on the wire scale.
#[test]
fn rage_and_happiness_divide_for_display_but_not_for_the_raw_readers() {
const RAGE: u8 = 1;
const HAPPINESS: u8 = 4;
const MANA: u8 = 0;
let warrior = ObjectFields::from_pairs(&[
(FIELD_UNIT_POWER1 + u16::from(RAGE), 350),
(FIELD_UNIT_MAXPOWER1 + u16::from(RAGE), 1000),
]);
assert_eq!(
warrior.unit_power(RAGE),
Some(350),
"the wire value, untouched"
);
assert_eq!(warrior.unit_shown_power(RAGE), Some(35));
assert_eq!(warrior.unit_shown_max_power(RAGE), Some(100), "not 1000");
let pet = ObjectFields::from_pairs(&[
(FIELD_UNIT_POWER1 + u16::from(HAPPINESS), 1_020_000),
(FIELD_UNIT_MAXPOWER1 + u16::from(HAPPINESS), 1_050_000),
]);
assert_eq!(
pet.unit_power(HAPPINESS),
Some(1_020_000),
"raw — the happiness bucket thresholds are on this scale"
);
assert_eq!(pet.unit_shown_power(HAPPINESS), Some(1020));
assert_eq!(pet.unit_shown_max_power(HAPPINESS), Some(1050));
// Mana/focus/energy divide by 1 — the shown and raw readers agree.
let caster = ObjectFields::from_pairs(&[
(FIELD_UNIT_POWER1 + u16::from(MANA), 4200),
(FIELD_UNIT_MAXPOWER1 + u16::from(MANA), 8000),
]);
assert_eq!(caster.unit_shown_power(MANA), caster.unit_power(MANA));
assert_eq!(
caster.unit_shown_max_power(MANA),
caster.unit_max_power(MANA)
);
// Absent stays nil rather than becoming a divided zero.
assert_eq!(caster.unit_shown_power(RAGE), None);
assert_eq!(caster.unit_shown_max_power(RAGE), None);
// The dead gate still wins over the divide, and only for the current value.
let feigning_warrior = ObjectFields::from_pairs(&[
(FIELD_UNIT_POWER1 + u16::from(RAGE), 350),
(FIELD_UNIT_MAXPOWER1 + u16::from(RAGE), 1000),
(FIELD_UNIT_DYNAMIC_FLAGS, 0x20),
]);
assert_eq!(feigning_warrior.unit_shown_power(RAGE), Some(0));
assert_eq!(feigning_warrior.unit_shown_max_power(RAGE), Some(100));
}
@@ -4,6 +4,38 @@
use super::*;
/// `UNIT_DYNAMIC_FLAGS` bit 5 — `UNIT_DYNFLAG_DEAD` (vmangos `SharedDefines.h:1158`). The client
/// reads it as "this body is a corpse" wherever it reads health for display; the server sets it for
/// **feign death** and for `CREATURE_FLAG_EXTRA_APPEAR_DEAD` spawns, in both cases with health left
/// intact. Every consumer goes through [`ObjectFields::unit_reads_dead`] (decision 1022).
const UNIT_DYNFLAG_DEAD: u32 = 0x20;
/// `UNIT_STAND_STATE_DEAD` (vmangos `UnitDefines.h:109`) — the third leg of the client's
/// reads-dead predicate `0x605f90`. vmangos never writes it, so it is inert against our server.
///
/// The client reads it through the virtual `[vtbl+0xa4]`, and the **two classes install different
/// functions there** — `CGUnit_C` `0x60be50`, `CGPlayer_C` `0x5ed570` (memoised at `[this+0x1d68]`
/// for the local player) — but both return the same byte, `[descr+0x210]` = `UNIT_FIELD_BYTES_1`
/// byte 0, which is exactly what [`ObjectFields::unit_stand_state`] reads (wow-re
/// `feign-death-dyndead.md` §1).
const STAND_STATE_DEAD: u8 = 7;
/// The raw→display divisor per power type — the reference's `0x6e7130`, a lookup into the table at
/// `0x86f978`: **`{1, 10, 1, 1, 1000}`** for MANA / RAGE / FOCUS / ENERGY / HAPPINESS, with `1` for
/// any out-of-range type (`test ecx,ecx; jl → 1`). Rage is stored ×10 on the wire and pet happiness
/// ×1000 (vmangos `GetCreatePowers`: `POWER_RAGE → 1000`, `POWER_HAPPINESS → 1050000`), so the real
/// client's 100 rage and 1050 happiness are this divide, not a different field.
///
/// Applied by `UnitMana`/`UnitManaMax` only — the raw accessors stay raw, and the pet happiness
/// bucket thresholds are on the RAW scale, which is why both forms have to exist (decision 1034).
fn power_display_scale(ty: u8) -> u32 {
match ty {
1 => 10, // rage
4 => 1000, // happiness
_ => 1,
}
}
/// Which field [`ObjectFields::unit_owner`] falls back to when `CHARMEDBY` is clear — the one
/// thing the client's two owner-guid readers do differently. Named rather than defaulted because
/// picking the wrong one is invisible until a totem or a guardian behaves like a pet.
@@ -101,14 +133,79 @@ impl ObjectFields {
pub fn unit_max_health(&self) -> Option<u32> {
self.get_u32(FIELD_UNIT_MAXHEALTH)
}
/// Whether the unit is **dead** (health 0 on a real unit). Absent health reads as **zero** — a
/// create block only carries *non-zero* fields (vmangos `_SetCreateBits`), so an already-dead
/// corpse streams in with `MAXHEALTH` but **no** `HEALTH` at all; the real client's descriptor
/// array is zero-initialized, so absent *is* 0 to it. The `MAXHEALTH > 0` guard keeps a unit
/// whose snapshot hasn't arrived (empty store) from reading as dead.
/// Whether the unit is **really dead** — health 0 on a real unit, the RAW field predicate.
/// Absent health reads as **zero** — a create block only carries *non-zero* fields (vmangos
/// `_SetCreateBits`), so an already-dead corpse streams in with `MAXHEALTH` but **no** `HEALTH`
/// at all; the real client's descriptor array is zero-initialized, so absent *is* 0 to it. The
/// `MAXHEALTH > 0` guard keeps a unit whose snapshot hasn't arrived (empty store) from reading
/// as dead.
///
/// This is the predicate for everything that keys on the body actually being a corpse — the
/// death arc and its release/ghost UI, loot and skinning, the selection's death-clear edge. For
/// **how the unit reads to the eye** — which a feigning hunter shares with a corpse — use
/// [`Self::unit_reads_dead`] (decision 1022).
pub fn unit_is_dead(&self) -> bool {
self.unit_max_health().unwrap_or(0) > 0 && self.unit_health().unwrap_or(0) == 0
}
/// Whether the unit **reads as dead** to the client — the reference's own one predicate
/// `0x605f90`, byte-exact: really dead (`[descr+0x40] <= 0`), **or** carrying
/// `UNIT_DYNFLAG_DEAD` (`0x605f9d`: `[descr+0x224] >> 5 & 1` — **feign death**), **or** stand
/// state 7 `UNIT_STAND_STATE_DEAD` (`0x605faa`: `vtbl+0xa4` == 7).
///
/// Feign death is exactly this bit and nothing else: vmangos `Unit::SetFeignDeath`
/// (`Unit.cpp:9491`/`9500`) sets and clears it while leaving `UNIT_FIELD_HEALTH` alone, and
/// `Object::BuildValuesUpdate` only rewrites `TRACK_UNIT`/`TAPPED` per viewer — so the flag
/// reaches **every** viewer, the feigner included. The client then reads dead everywhere it
/// matters: `UnitHealth`/`UnitMana` answer 0 ([`Self::unit_shown_health`],
/// [`Self::unit_shown_power`]), `UnitIsDead 0x517ac0` answers true, the animation chain's top
/// resolver `0x5fcff0` claims and holds the corpse pose, and the plate/loop-sound gates drop.
///
/// The stand-state leg is the reference's; vmangos never writes stand state 7, so against our
/// server the triple and the narrower `UnitIsDead 0x517ac0` pair (health ≤ 0 ∨ dynflag)
/// coincide.
pub fn unit_reads_dead(&self) -> bool {
self.unit_is_dead()
|| self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0
|| self.unit_stand_state() == STAND_STATE_DEAD
}
/// Health **as the UI reads it** — the `UnitHealth 0x5174d0` law, byte-exact: a unit carrying
/// `UNIT_DYNFLAG_DEAD` answers **0** (`0x51753c`–`0x517551`), everything else answers the raw
/// field clamped at zero (`0x517553`–`0x517560`: `setle`/`dec`/`and`). So a feigning hunter's
/// health bar empties for himself and for everyone watching.
///
/// Deliberately NOT mirrored on the max: `UnitHealthMax 0x5175b0` reads `[descr+0x58]` with no
/// gate at all, which is what makes the bar render `0/max` (empty) rather than vanish.
pub fn unit_shown_health(&self) -> Option<u32> {
if self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 {
return Some(0);
}
self.unit_health()
}
/// Power **as the UI reads it** — the `UnitMana 0x517670` law, both halves (decision 1034):
/// the `UNIT_DYNFLAG_DEAD` gate (`0x5176d6`–`0x5176e6`) ahead of the power-slot read, and then
/// the **raw→display divide** by [`power_display_scale`] (`0x517704 call 0x6e7130; 0x51770f
/// div ecx`). Rage rides the wire ×10 and pet happiness ×1000; without the divide a warrior's
/// bar reads `0/1000` and a pet's `…/1050000`.
///
/// `UnitManaMax 0x5177e0` divides too (`0x517864`) but has **no** dead gate, so the bar empties
/// against a real maximum — see [`Self::unit_shown_max_power`].
///
/// Not transcribed: the `powerType == POWER_HEALTH (-2)` substitution at `0x5176f7`. The
/// reference means to read HEALTH there, but `0x5176e8 movzx` zero-extends the byte while
/// `0x5176ec cmp edx,0xfffffffe` sign-extends its imm8, so the equality can never hold and the
/// leg is **dead code as compiled in 5875** (wow-re `feign-death-dyndead.md` §3, both §5
/// workers independently). Rendering it would be reproducing a bug, not the behaviour.
pub fn unit_shown_power(&self, ty: u8) -> Option<u32> {
if self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 {
return (ty < 5).then_some(0);
}
Some(self.unit_power(ty)? / power_display_scale(ty))
}
/// Maximum power **as the UI reads it** — `UnitManaMax 0x5177e0`: the same
/// [`power_display_scale`] divide (`0x517864`), and deliberately **no** dead gate.
pub fn unit_shown_max_power(&self, ty: u8) -> Option<u32> {
Some(self.unit_max_power(ty)? / power_display_scale(ty))
}
/// `UNIT_FIELD_LEVEL` — the unit's level.
pub fn unit_level(&self) -> Option<u32> {
self.get_u32(FIELD_UNIT_LEVEL)
@@ -269,7 +366,8 @@ impl ObjectFields {
self.get_f32(FIELD_UNIT_BOUNDINGRADIUS).unwrap_or(0.0)
}
/// `UNIT_DYNAMIC_FLAGS` — per-viewer dynamic state; bit `0x1` = **lootable** (the cursor's Loot
/// branch — wow-re cursor RE, client `+0x224`). Absent counts as 0.
/// branch — wow-re cursor RE, client `+0x224`), bit `0x2` = tracked (Hunter's Mark), bit `0x20`
/// = **dead-looking** ([`Self::unit_reads_dead`] — feign death). Absent counts as 0.
pub fn unit_dynamic_flags(&self) -> u32 {
self.get_u32(FIELD_UNIT_DYNAMIC_FLAGS).unwrap_or(0)
}
@@ -569,6 +569,79 @@ impl WorldSession {
)
}
/// Send a chat line on an arbitrary lane — the generalised twin of [`Self::send_chat`]
/// (which is this with `CHAT_TYPE_SAY`). `target` is the whisper target on
/// `CHAT_TYPE_WHISPER` and the channel name on `CHAT_TYPE_CHANNEL`, `None` elsewhere. Spoken
/// in the character's own tongue, with the same server-side rules.
pub fn send_chat_kind(
&mut self,
chat_type: u32,
target: Option<&str>,
message: &str,
) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
&messages::messagechat_kind(chat_type, self.chat_language, target, message),
)
}
/// Join a channel — the unsplit twin of [`WorldWriter::join_channel`]; `password` is empty for
/// a channel that has none.
pub fn join_channel(&mut self, name: &str, password: &str) -> Result<()> {
self.send(
opcode::CMSG_JOIN_CHANNEL,
&messages::join_channel(name, password),
)
}
/// Leave a channel — the unsplit twin of [`WorldWriter::leave_channel`].
pub fn leave_channel(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_LEAVE_CHANNEL, &messages::leave_channel(name))
}
/// Invite a player to our group by name (`CMSG_GROUP_INVITE`); they answer with
/// `SMSG_GROUP_INVITE` and, if they want in, [`Self::group_accept`].
pub fn group_invite(&mut self, member_name: &str) -> Result<()> {
self.send(
opcode::CMSG_GROUP_INVITE,
&messages::group_invite(member_name),
)
}
/// Accept the group invite we were just offered (`CMSG_GROUP_ACCEPT`, empty body).
pub fn group_accept(&mut self) -> Result<()> {
self.send(opcode::CMSG_GROUP_ACCEPT, &messages::group_accept())
}
/// Leave/disband our group (`CMSG_GROUP_DISBAND`, empty body) — probes tear the group down so
/// the next run starts ungrouped.
pub fn group_disband(&mut self) -> Result<()> {
self.send(opcode::CMSG_GROUP_DISBAND, &messages::group_disband())
}
/// Send an **addon message** — a `CMSG_MESSAGECHAT` on `chat_type`'s lane with
/// [`messages::LANGUAGE_ADDON`] in the language field, which is what makes it addon traffic
/// rather than speech (decision 1029). `target` is the channel name on
/// [`messages::CHAT_TYPE_CHANNEL`] and `None` on the group/guild lanes; `text` is the raw
/// payload (`prefix`, TAB, the addon's own encoding) — this verb does not compose it.
///
/// Probe-only, and deliberately: benilla runs FrameXML, not third-party addons, so it has
/// nothing to *say* over this lane. It exists so the receive gate can be proved against the
/// live server (`addon_chat_probe`) rather than argued from folk memory of the lane. The
/// server drops the whole message unless `AddonChannel` is on and `chat_type` is one of the
/// lanes `IsLanguageAllowedForChatType` permits (see [`messages::LANGUAGE_ADDON`]).
pub fn send_addon_message(
&mut self,
chat_type: u32,
target: Option<&str>,
text: &str,
) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
&messages::messagechat_kind(chat_type, messages::LANGUAGE_ADDON, target, text),
)
}
/// Echo a same-map teleport ack — the unsplit twin of [`WorldWriter::teleport_ack`] (without it
/// the server freezes our movement after a `.go`).
pub fn teleport_ack(&mut self, guid: u64, counter: u32) -> Result<()> {
+61
View File
@@ -414,3 +414,64 @@ fn the_macro_expanded_chat_types_are_the_reference_wire_gate() {
);
}
}
/// **Addon chat is ordinary chat with a sentinel language** (decision 1029, B215). 1.12.1 has no
/// addon opcode and no addon `ChatMsg` type: a hunter addon's version ping reaches the client as a
/// `CHAT_MSG_PARTY` like any other, and only `language == LANG_ADDON` (`0xFFFFFFFF`) separates it
/// from someone talking. Miss that field and it renders — which is exactly what printed
/// `[Party] [Soreen]: Quiver VERSION:3.1.4` in a mixed-client party.
///
/// The bytes below are the shape `addon_chat_probe` observed coming back off the **live** vmangos
/// on both lanes it tested (`cargo run -p benilla-protocol --example addon_chat_probe`): the
/// reported PARTY lane and the CHANNEL lane. Note what the payload is — `prefix`, a TAB, then the
/// addon's own encoding — and that the server passes it through untouched (addon chat skips
/// `SanitizeChatMessage` entirely, vmangos `Handlers/ChatHandler.cpp:49`).
#[test]
fn addon_chat_is_an_ordinary_type_with_the_sentinel_language() {
// PARTY (the reported case): the two-guid shape, language 0xFFFFFFFF, "Quiver\tVERSION:3.1.4".
let body = hx("01ffffffff21000000000000002100000000000000150000005175697665720956455253494f4e3a332e312e340000");
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
ServerPacket::MessageChat(m) => {
assert_eq!(
m.chat_type,
messages::CHAT_MSG_PARTY,
"no addon-only type byte"
);
assert_eq!(m.language, messages::LANGUAGE_ADDON);
assert_eq!(m.text, "Quiver\tVERSION:3.1.4");
assert!(m.is_addon());
}
_ => panic!("expected MessageChat"),
}
// The control, same lane, same shape: speech must not be caught by a language-keyed gate. The
// live server hands party speech back as LANG_UNIVERSAL (0) — `HandleChatMessageOpcode`
// rewrites the tongue for GMs and for two-side-enabled lanes, and the one language it never
// rewrites is LANG_ADDON (the whole normalisation block sits in that `if`'s `else`).
let body = hx("01000000002100000000000000210000000000000013000000706172747920636f6e74726f6c206c696e650000");
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
ServerPacket::MessageChat(m) => {
assert_eq!(m.chat_type, messages::CHAT_MSG_PARTY);
assert_eq!(m.text, "party control line");
assert!(!m.is_addon(), "speech must survive the addon gate");
}
_ => panic!("expected MessageChat"),
}
// Every tongue a character can actually speak is speech, sentinel or not — the gate is an
// equality test against 0xFFFFFFFF, never a range or a "not a known language" test.
for lang in [0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] {
let m = messages::ChatMessage {
chat_type: messages::CHAT_MSG_PARTY,
language: lang,
sender_guid: 0x21,
target_guid: 0x21,
sender_name: None,
channel: None,
text: "hello".into(),
chat_tag: 0,
};
assert!(
!m.is_addon(),
"language {lang} is a tongue, not the sentinel"
);
}
}
+52 -6
View File
@@ -361,15 +361,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetButtonState",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
// The LIVE mouse-held press counts too (hovered + LeftButton down on this frame —
// the same predicate the extract pass renders the PushedTexture by), closing the
// documented INTERIM where a mouse-held button answered "NORMAL": the chat scroll
// The LIVE mouse-held press counts too (hovered + a registered button down on this
// frame — the same predicate the extract pass renders the PushedTexture by), closing
// the documented INTERIM where a mouse-held button answered "NORMAL": the chat scroll
// buttons' hold-repeat (ref MessageFrameScrollButton_OnUpdate) polls exactly this
// mid-press (decision 0288 P3).
// mid-press (decision 0288 P3). It reads the same in the reference for the same
// reason — `0x7792ad` writes ONE state variable and `0x780180` reads it back, so a
// right-held button answers "PUSHED" as surely as a left-held one.
let held = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.mouseover == Some(h)
&& model.mouse_down_on.get("LeftButton").copied() == Some(h)
model.mouseover == Some(h) && press_held(&model, h)
};
with_button(lua, &this, move |bs| {
if !bs.enabled {
@@ -456,6 +457,51 @@ pub(super) fn wants_click(model: &Model, h: FrameHandle, name: &str) -> bool {
}
}
/// Whether frame `h` is registered for `button` (`"LeftButton"`, `"RightButton"`, …) in **either**
/// variant — the press-visual gate, which is a strictly weaker test than [`wants_click`]'s.
///
/// `CButton::OnMouseDown 0x779210` runs the two as separate tests in one pass, and the difference
/// between them is the whole of this:
///
/// ```text
/// 0x779238 eax = event.buttonMask
/// 0x779243 ecx = eax << 8 | eax
/// 0x77924b test [this+0x330], ecx ; registered EITHER WAY -> keep going, else nothing at all
/// 0x779256 hit-test the point ; else nothing
/// 0x77926b test event.buttonMask, [this+0x330] ; the DOWN bits alone -> fire OnClick
/// 0x7792ad push 2 ; SetState(PUSHED) ; unconditional past the gates above
/// ```
///
/// So `[this+0x330]` is one dword holding the `"…ButtonDown"` registrations in byte 0 and the
/// `"…ButtonUp"` registrations in byte 1, and the mask `m | m << 8` asks "this button, either
/// variant". **The pushed art is not conditional on the click firing, or on the handler doing
/// anything** — it is conditional only on the button being registered for that mouse button at
/// all, which is why a right-click on an action, spellbook or pet slot lights up even when the
/// right-click does nothing (they all register `RightButtonUp`), and why a right-click on a plain
/// `LeftButtonUp` button does not.
pub(super) fn wants_press_visual(model: &Model, h: FrameHandle, button: &str) -> bool {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Button(bs)) => bs.registered_clicks.iter().any(|s| {
s.strip_suffix("Up")
.or_else(|| s.strip_suffix("Down"))
.is_some_and(|b| b.eq_ignore_ascii_case(button))
}),
_ => false,
}
}
/// Is a press currently holding this button down — the `held` half of the PushedTexture rule.
///
/// Any captured mouse button counts, gated by [`wants_press_visual`]. Callers pair it with
/// `hovered`, which is `0x779256`'s hit test at press time plus `0x7793f0`'s restore-to-NORMAL
/// when the pointer leaves mid-press.
pub(super) fn press_held(model: &Model, h: FrameHandle) -> bool {
model
.mouse_down_on
.iter()
.any(|(button, &pressed)| pressed == h && wants_press_visual(model, h, button))
}
/// The click behavior shared by the input path and `Click()`: gated on `enabled`; a CheckButton
/// **toggles before OnClick fires** (the documented widget contract — a handler reading
/// `self:GetChecked()` sees the new state); then `OnClick(self, button, down)` — `down` is `true`
+4 -2
View File
@@ -104,8 +104,10 @@ impl UiScript {
owner_frame.map(|f| &f.kind_state)
{
let hovered = owner.is_some() && model.mouseover == owner;
let held = owner.is_some()
&& model.mouse_down_on.get("LeftButton").copied() == owner;
// ANY registered mouse button holds a button down, not only the left one
// (`0x77924b`, see `button::wants_press_visual`) — which is what makes a
// right-click on a bar or spellbook slot flash its pushed art.
let held = owner.is_some_and(|o| super::button::press_held(&model, o));
if !bs.region_visible(rh, hovered, held) {
continue;
}
@@ -87,6 +87,70 @@ fn button_state_textures_switch_with_interaction() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **A right-click lights a button up too** — the director's report, and `0x77924b`'s law.
///
/// `CButton::OnMouseDown` gates the PushedTexture on `[this+0x330] & (m | m << 8)`: the button
/// registered for that mouse button in **either** variant. Not on the click firing, not on the
/// handler doing anything — which is why right-clicking an action, spellbook or pet slot flashes,
/// while right-clicking a default `LeftButtonUp` button does not.
#[test]
fn any_registered_mouse_button_shows_the_pushed_texture() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local function slot(name, x)
local b = CreateFrame("Button", name)
b:SetPoint("BOTTOMLEFT", x, 0); b:SetSize(100, 100)
b:SetNormalTexture("Interface\\" .. name .. "N.blp")
b:SetPushedTexture("Interface\\" .. name .. "P.blp")
return b
end
-- A bar slot: both buttons registered, exactly as ActionButton/PetActionButton do.
slot("Bar", 0):RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- A plain button: the default set, {LeftButtonUp}.
slot("Plain", 200)
"#,
)
.unwrap();
s.resolve();
let shows = |s: &UiScript, path: &str| {
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
};
// The bar slot lights under EITHER button.
for button in ["LeftButton", "RightButton"] {
s.mouse_move(50.0, 50.0);
s.mouse_button(50.0, 50.0, button, true);
assert!(
shows(&s, "Interface\\BarP.blp") && !shows(&s, "Interface\\BarN.blp"),
"{button} down must show the pushed art"
);
assert_eq!(
s.eval::<String>("return Bar:GetButtonState()").unwrap(),
"PUSHED",
"and the state variable the engine writes is one variable ({button})"
);
s.mouse_button(50.0, 50.0, button, false);
assert!(shows(&s, "Interface\\BarN.blp"), "the release restores it");
}
// The plain button lights under the left only — `0x77924b` is a real gate, not a formality.
s.mouse_move(250.0, 50.0);
s.mouse_button(250.0, 50.0, "RightButton", true);
assert!(
shows(&s, "Interface\\PlainN.blp") && !shows(&s, "Interface\\PlainP.blp"),
"an unregistered button must not light"
);
s.mouse_button(250.0, 50.0, "RightButton", false);
s.mouse_button(250.0, 50.0, "LeftButton", true);
assert!(shows(&s, "Interface\\PlainP.blp"));
s.mouse_button(250.0, 50.0, "LeftButton", false);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetButtonState`/`GetButtonState` (`0x780270`/`0x780180`) — the scripted press state the ref's
/// `ActionButtonDown/Up` keybind pair drives: PUSHED shows the pushed texture with no mouse
/// involved, NORMAL restores, unknown states error, and a disabled button answers DISABLED.