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:
Generated
+1
@@ -596,6 +596,7 @@ dependencies = [
|
||||
"bevy_egui",
|
||||
"bytemuck",
|
||||
"cosmic-text",
|
||||
"cpal 0.17.1",
|
||||
"crossbeam-channel",
|
||||
"fontdb",
|
||||
"half",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user