pets get their action bar, macros arrive, and hunters draw the bow

- a hunter's or warlock's pet has its action bar now: attack, follow, stay, the pet's own spells with cooldowns and autocast sparkles
- the macro system lands: the macro window, the icon chooser, /cast and /macro, and macros dragged onto the action bar
- hunter shots look right at last: the bow draws and releases on the shot, and the arrow lays a spark trail its whole flight
- the options window re-dresses in 1.12's own art with the classic dialog look, and gets a working search box and clickable steppers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-05 19:03:13 +07:00
co-authored by Claude Fable 5
parent 994be22f5c
commit 77dc8f8ead
98 changed files with 8922 additions and 1089 deletions
-87
View File
@@ -1,87 +0,0 @@
//! The app side of the Era atlas seam (decision 0950): read the extractor's manifest once at
//! boot and turn it into the [`UiScript`](benilla_ui::script::UiScript) atlas table, whose
//! entries carry `era:`-scheme texture paths that [`super::WorldAssets::sprite_texture`]
//! resolves from disk. Runtime never touches CASC or DB2 — `scripts/era-extract.py` baked the
//! `UiTextureAtlas` indirection offline into `WoW-era/_extracted_ui/{manifest.json,
//! textures/<fdid>.blp}`; a missing or stale extraction WARNs and draws blank, never fails the
//! load (the misses name themselves through `take_era_atlas_misses`).
use benilla_ui::script::EraAtlasEntry;
use serde::Deserialize;
/// The extracted Era UI assets root — through the `WoW-era` symlink `wt.sh link_wow` provisions
/// in every slot (same `CARGO_MANIFEST_DIR` convention as the MPQ `Data` default elsewhere in
/// this crate: the path follows the worktree that built the binary).
pub(crate) fn era_ui_root() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW-era/_extracted_ui")
}
/// `manifest.json`, exactly as `scripts/era-extract.py` writes it (the fields we consume).
#[derive(Deserialize)]
struct Manifest {
build: String,
atlases: std::collections::HashMap<String, Member>,
}
#[derive(Deserialize)]
struct Member {
/// Relative to the extraction root (`textures/<fdid>.blp`).
file: String,
/// The sheet's dimensions in pixels.
atlas_size: [f32; 2],
/// Pixel edges `[left, top, right, bottom)` within the sheet.
rect: [f32; 4],
/// Nominal draw size in UI units (`useAtlasSize`).
size: [f32; 2],
}
/// Load and normalize the manifest into UiScript table entries: pixel rects become the
/// `[left, right, top, bottom]` UV form `TexCoords::Rect` stores; files ride the `era:` scheme.
/// `None` = no usable manifest (absent install, extraction never run, or a parse error — each
/// WARNs with the fix spelled out; the distinction matters when diagnosing a blank window).
pub(crate) fn load_era_atlases() -> Option<Vec<(String, EraAtlasEntry)>> {
let path = era_ui_root().join("manifest.json");
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
bevy::log::warn!(
"era: no UI manifest at {} ({e}) — Era-styled windows will draw blank; \
run scripts/era-extract.py",
path.display()
);
return None;
}
};
let manifest: Manifest = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(e) => {
bevy::log::warn!(
"era: manifest.json unparseable ({e}) — re-run scripts/era-extract.py"
);
return None;
}
};
bevy::log::debug!(
"era: atlas manifest for build {} — {} members",
manifest.build,
manifest.atlases.len()
);
Some(
manifest
.atlases
.into_iter()
.map(|(name, m)| {
let [aw, ah] = m.atlas_size;
let [l, t, r, b] = m.rect;
(
name,
EraAtlasEntry {
file: format!("era:{}", m.file),
uv: [l / aw, r / aw, t / ah, b / ah],
size: [m.size[0], m.size[1]],
},
)
})
.collect(),
)
}
-20
View File
@@ -27,9 +27,6 @@ use benilla_formats::{open_chain, read_texture_mip_chain, read_texture_rgba, Cha
mod images;
pub(crate) use images::*;
/// The Era atlas manifest side of decision 0950 (`era:` texture scheme + the UiScript table).
pub(crate) mod era;
/// Shared asset store: the **one** open patch chain plus dedup caches, used by every model/texture
/// load (terrain layers, doodads, WMOs, creatures, GameObjects). Replaces the three independent
/// chains we used to open. Deduping by path/material means each unique BLP is decoded + uploaded
@@ -181,23 +178,6 @@ impl WorldAssets {
path: &str,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
// `era:<relpath>` (decision 0950): a loose extracted file under
// `WoW-era/_extracted_ui/`, never the MPQ chain — the scheme string is its own cache key
// (verbatim, case-sensitive: the manifest writes it, authors copy it).
if let Some(rel) = path.strip_prefix("era:") {
if let Some(cached) = self.sprites.get(path) {
return cached.clone();
}
let loaded = std::fs::read(era::era_ui_root().join(rel))
.ok()
.and_then(|bytes| benilla_formats::blp_to_rgba(&bytes).ok())
.map(|(w, h, rgba)| images.add(sprite_image(w, h, rgba)));
if loaded.is_none() {
warn!("era: sprite '{path}' unreadable — stale extraction? run scripts/era-extract.py");
}
self.sprites.insert(path.to_string(), loaded.clone());
return loaded;
}
// FrameXML/DBC texture references are canonically EXTENSIONLESS
// (`Interface\Buttons\UI-Quickslot2`) — the real client appends `.blp` at resolve. Paths
// that already carry an extension (`textures\moon.blp`) pass through unchanged.
+18
View File
@@ -958,6 +958,24 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-options-graphics seed failed: {e}");
}
}
UiFixture::OptionsSearch => {
let Some(mut script) = script else {
return;
};
// Mid-search (0984), same posture as the page fixtures: real CVar set, then the
// live open path and a typed query — "volume" reflows the four volume sliders
// under the Audio head (Master matched by the token too, so no pull-in here; the
// pull-in has its unit test). Focused (0989): captures pin the caret visible, so
// this baseline also pins the caret hugging the text's end — the drawn-space
// advance law's visual regression guard.
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); OptionsFrameSearchBox:SetText(\"volume\"); OptionsFrameSearchBox:SetFocus()",
)
{
warn!("capture: ui-options-search seed failed: {e}");
}
}
UiFixture::SpellBook => {
// The director's own report reproduced (decision 0228): a HUMAN WARRIOR (race 1,
// class 1) who learned Fireball + Mind Flay via a GM command during testing. The book
+45
View File
@@ -105,6 +105,12 @@ pub(super) enum UiFixture {
/// defaults (hermetic capture = no config file), so the pixels move only with the window,
/// the atlas seam, or a registered default.
OptionsGraphics,
/// The Options window MID-SEARCH (decision 0984) — the results-view look instrument: the
/// "volume" query reflows the four live volume sliders under the clickable Audio head
/// (GameFontNormalLarge), title "Search Results", Defaults hidden, the clear-X shown in
/// the box. Hermetic like the other options fixtures; pixels move only with the window
/// or a registered default.
OptionsSearch,
/// A **floating overhead name with the river surface behind it** — the world-text-vs-liquid
/// draw-order instrument. A named unit stands 25 yd out in the Elwynn river (the `water-noon`
/// camera), so its name projects onto the water *beyond* it: the exact geometry of the
@@ -156,6 +162,7 @@ pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0];
/// `Map.dbc` ids the golden spots stand on.
pub(super) const MAP_AZEROTH: u32 = 0;
pub(super) const MAP_KALIMDOR: u32 = 1;
pub(super) const MAP_DEEPRUN_TRAM: u32 = 369;
/// THE golden baseline: **three spots FRAMED BY THE DIRECTOR, each at noon and at night — six
/// captures, and that is the whole sweep.** A Stormwind canal view, Elwynn water, and a Felwood
@@ -327,6 +334,28 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=<name>`) for
/// debugging and look passes, but NOT part of the blessed baseline sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
// ---- The Deeprun Tram's undersea tube (map 369) ----
// The one shipped map with NO `Light.dbc` row at all — not even the falloff-0 global that maps
// 0/1 carry — so its whole atmosphere has to come from the building's own MFOG (record 2:
// RGB(30,53,100), end 236.1 yd, start scalar 0.05; the camera's group `Subway_002` names it).
// A global (WDT `MODF`) WMO too, the placement shape no other scenario exercises.
//
// ⚠ **This shot currently photographs NOTHING — do not baseline it.** The building registers
// ("terrain: map DeeprunTram has no tiles — its world is one WMO") but no group ever reaches
// the frame in server-less capture, so the shot is the bare sky dome from any eye, inside the
// tube or between the tubes, at `WOW_CAPTURE_STABLE` 30 or 1800 alike (the 1800 run reported
// "never settled", i.e. it was still waiting, not still loading). The live client draws this
// map fine — the director's screenshots are of it — so the gap is the harness's, and it is
// kept here as the reproducer rather than deleted: a WMO-only map is exactly the residency
// shape 0688 wired and nothing has ever photographed it.
Scenario {
name: "tram-undersea",
map: MAP_DEEPRUN_TRAM,
eye: TRAM_EYE,
look: TRAM_LOOK,
minute: 720,
ui: None,
},
// ---- Evicted from the blessed sweep by decision 0817, NOT deleted ----
// The sweep had grown from 0632's six to twenty-one, i.e. 42 windows on the director's screen per
// `selfcheck`, and the director cut it back to three spots x two day times. Everything below this
@@ -858,6 +887,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::OptionsGraphics),
},
// The same window MID-SEARCH (0984): the "volume" results view. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-search`.
Scenario {
name: "ui-options-search",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::OptionsSearch),
},
// An overhead NAME with the river surface behind it — the world-text-vs-liquid draw-order
// instrument (see [`UiFixture::NameWater`]). Same camera as `water-noon`, plus a named unit
// out in the water. Run with `WOW_CAPTURE=name-water`.
@@ -870,3 +909,9 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::NameWater),
},
];
/// The Deeprun Tram undersea tube — see the `tram-undersea` scenario. Raw WoW coords; the Subway
/// WMO is the map's global `MODF` at the origin with identity rotation, so these are also its
/// model-space coords.
pub(super) const TRAM_EYE: [f32; 3] = [-2.44, -1250.0, -120.0];
pub(super) const TRAM_LOOK: [f32; 3] = [-2.44, -1400.0, -118.0];
+37
View File
@@ -473,6 +473,43 @@ impl Cooldowns {
);
}
/// One `SMSG_PET_SPELLS` cooldown entry (decision 0982) — [`Self::seed_initial`]'s pet twin,
/// separate because the pet block's ids are `u32` where the player's login list packs them
/// into `u16`, and because its category duration carries a marker bit the player's does not.
///
/// Both remainders are what is LEFT (vmangos computes them at send, `WritePetSpellsCooldown`),
/// so the record starts now and runs the remainder. The category word's
/// [`PET_COOLDOWN_PERMANENT`] bit marks a server-re-armed cooldown; it is stripped, because
/// carrying it would read as a ~37-hour sweep on the button.
pub(crate) fn seed_pet(
&mut self,
cd: &benilla_protocol::messages::PetSpellCooldown,
spell: Option<&SpellDisplay>,
now: Instant,
) {
use benilla_protocol::messages::PET_COOLDOWN_PERMANENT;
let category_ms = cd.category_cd_ms & !PET_COOLDOWN_PERMANENT;
self.add(
cd.spell_id,
0,
Timer {
start: now,
duration: Duration::from_millis(u64::from(cd.spell_cd_ms)),
},
u32::from(cd.category),
// The wildcard row's flag comes off the catalog when we have it — the same resolve
// `start_spell` does. Absent catalog ⇒ false, the non-wildcard reading.
spell.is_some_and(|s| s.category_wildcard),
Timer {
start: now,
duration: Duration::from_millis(u64::from(category_ms)),
},
false,
0,
Timer::none(now),
);
}
/// The client's `IsSpellOnCooldown 0x6e1690` — an **"has an on-hold (not-yet-started)
/// record"** predicate, NOT a general on-cooldown test (the corrected decode, wow-re
/// `gcd-power-gate.md` §3: both legs return 1 only when the matched node's onHold byte is
+5 -2
View File
@@ -125,8 +125,11 @@ pub(crate) struct Engaged;
/// the local cast-send). A REMOTE shooter's drawn idle rides [`RangedHold`] instead. Cleared
/// only by [`cancel_auto_repeat_local`]'s callers — the client's cancel `0x6ea080` (its `0x200`
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and the
/// (vmangos-dead) `SMSG_CANCEL_AUTO_REPEAT` (wow-re `nocked-ammo-cancel.md`).
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and
/// `SMSG_CANCEL_AUTO_REPEAT` — which vmangos DOES send (corrected 2026-08-05; see
/// [`crate::net::apply::spells::cancel_auto_repeat`]), so against a live server that packet, not
/// a local death watcher, is what ends a volley whose target dies (wow-re
/// `nocked-ammo-cancel.md`).
#[derive(Component)]
pub(crate) struct AutoRepeatArmed;
+41 -5
View File
@@ -35,7 +35,7 @@ use super::select::{
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
use super::{
find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, CastHold, DefenseAnim,
EmoteAnim, Engaged, MovementState, Overlay, OverlayFade, RangedHold, SheathRequest,
EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, RangedHold, SheathRequest,
SheathSwapMessage, SwingImpact, SwingMessage, SwingSlowdown, Wielded, WoundAnim,
};
@@ -286,6 +286,10 @@ pub(super) fn drive_animations(
Has<SelfPlayer>,
Option<&crate::entities::mount::MountBody>,
Has<crate::net::CreatureSwimming>,
// Is the in-hand arrow currently latched (the `$BWP`..`$BWR` window)? Read only by
// the leaving-the-stance un-nock at the tail of this pass, which is edge-triggered
// off it rather than issuing a remove every frame.
Has<NockLatch>,
// The rendered model scale — `OBJECT_FIELD_SCALE_X`, baked onto the entity transform by
// `entities::attach`. The locomotion playback rate divides by it (decision 0903), so an
// ogre at 2.2× cycles its legs 2.2× slower than a same-speed human. Read-only: nothing
@@ -431,7 +435,16 @@ pub(super) fn drive_animations(
wielded,
cast_hold,
facing_step,
(engaged, auto_repeat, ranged_hold, is_self, mount_body, creature_swimming, transform),
(
engaged,
auto_repeat,
ranged_hold,
is_self,
mount_body,
creature_swimming,
nock_latched,
transform,
),
) in &mut units
{
// A mount child drives from its HOST's movement view (decision 0441) — same inputs the
@@ -1260,9 +1273,12 @@ pub(super) fn drive_animations(
// Q-I, decision 0412): mid-volley the client plays the FULL fire clip (whose tail
// is the visible quick re-nock) and returns to the hold; it NEVER replays the Load
// and NEVER rate-scales a bow clip (the `0x5fee80` scaler gate excludes every bow
// id). 0411's RENOCK_RATE replay is refuted and removed. `ranged_hold` joins the
// gate so a REMOTE shooter's fire clip returns to its hold the same way.
if played_oneshot.is_some() && (auto_repeat || ranged_hold) && drv.sheath_cur == Some(2) {
// id). 0411's RENOCK_RATE replay is refuted and removed. Same gate as the idle's
// own ([`select::ranged_idle_gate`]) so the two cannot drift: a unit that could
// not have entered the family has no hold to return to.
if played_oneshot.is_some()
&& select::ranged_idle_gate(auto_repeat, ranged_hold, drv.ranged_held, drv.sheath_cur)
{
drv.ranged_held = true;
}
@@ -1357,5 +1373,25 @@ pub(super) fn drive_animations(
.entity(entity)
.remove::<(super::NockedAmmo, super::NockLatch)>();
}
// …and MOVING un-nocks too (the arrow leaves the hand, the string relaxes; the cached
// ammo display survives for the next pull). The nock latch has exactly two authored
// writers — `$BWP` sets, `$BWR` clears — and both live in clips only a STANDING unit
// plays: the Load pull and the fire clip. Nothing in a run cycle can ever clear it, so a
// latch carried into locomotion is permanent: the director's report and screenshot
// (2026-08-05) are a warrior sprinting with the arrow still nocked and the bowstring
// still drawn. The hold-pick re-latch (decision 0409's INTERIM, still in the gait arm)
// guarantees the leak on every volley — it re-latches each time the drawn hold is picked,
// with no writer of its own to undo it.
//
// **Named approximation, director-grounded.** The reference's un-nock `0x60f530` has six
// callers; three (`0x6020ef`, `0x60c951`, `0x60e4ac`) are still uncensused (dispatched to
// wow-re 2026-08-05, alongside the asset finding that AttackBow(46) authors NO `$BWP` —
// only LoadBow/LoadRifle do — which refutes the Q-I §I2 re-attach inference our INTERIM
// stands in for). One of the three may well be exactly this edge. Until that lands, the
// rule is what the screen requires and what closes our state machine: every latch we can
// set has an owner that tears it down.
if moving && nock_latched {
commands.entity(entity).remove::<super::NockLatch>();
}
}
}
+23 -17
View File
@@ -365,13 +365,14 @@ pub(super) fn run(
// Normal gait: select, cross-fade on change, keep the rate synced each frame.
// The engaged standing idle: the weapon-class Ready pick (decision 0073).
let ready = (engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.main)));
// The ranged standing idle (0099 phase 5): sheath ranged + either armed
// bit → the ranged weapon's Load/Hold clip. `auto_repeat` is the local
// `0x200` (the resolver `0x5fd460`'s own gate); `ranged_hold` is the
// any-caster `0x400` weapon-visual hold — what puts a REMOTE shooter (an
// NPC archer, another hunter) in the drawn idle between shots (HoldBow
// sustains on `0x200|0x400`, rifle/thrown on `0x400`; the per-weapon
// asymmetry is folded into this one gate — no benilla-visible difference).
// The ranged standing idle (0099 phase 5): the byte-verified entry/sustain
// gate ([`select::ranged_idle_gate`]) → the ranged weapon's Load/Hold clip.
// ENTRY is the local `0x200` ([`auto_repeat`]) alone — the resolver
// `0x5fd460`'s own and only claim test; the any-caster `0x400`
// ([`ranged_hold`]) merely SUSTAINS a hold already on the base track
// (`0x5fc3f0`'s self-loop). Folding the two into one entry gate is what
// left a shooter permanently aiming after any single ranged spell and a
// remote archer permanently drawn (director-reported 2026-08-05).
// After one full pass of the Load clip the idle swaps to the drawn HOLD
// twin — nock once, then HOLD through every shot: a mid-volley fire clip
// returns straight to the hold, never a full re-pull (director-refuted;
@@ -382,16 +383,21 @@ pub(super) fn run(
// `0x200`-gated resolver) would leave a remote in plain Stand — unobserved
// on the ref, and the derivation rests on the LOAD-ANIM-RECOMPUTE-MODE
// open item; revisit if the ref's remote hunters visibly differ.
let ranged_load =
((auto_repeat || ranged_hold) && !moving && drv.sheath_cur == Some(2))
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)))
.map(|id| {
if drv.ranged_held {
select::ranged_hold_anim(id)
} else {
id
}
});
let ranged_load = (!moving
&& select::ranged_idle_gate(
auto_repeat,
ranged_hold,
drv.ranged_held,
drv.sheath_cur,
))
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)))
.map(|id| {
if drv.ranged_held {
select::ranged_hold_anim(id)
} else {
id
}
});
let cands = gait_candidates(&mv, walk, ready, ranged_load);
// The stationary cast/channel hold pins its pose **full-body in the gait slot**
// (decision 0107 — the client's `[CGUnit+0xb4]` stationary-cast gate),
@@ -2628,3 +2628,137 @@ fn the_dismount_cuts_the_saddle_pose_where_the_mount_up_blends_into_it() {
"the dismount CUTS: Mount(91) contributes nothing the frame the field clears"
);
}
/// A shooter's model: Stand, Run, and the bow Load/Hold pair.
fn archer_model() -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(5, 2, true), // Run
clip(105, 3, false), // LoadBow — the pull
clip(109, 4, true), // HoldBow — the drawn hold
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
/// The bow-and-arrow half of the director's 2026-08-05 report — the screenshot is a warrior
/// **sprinting** with the arrow still nocked and the bowstring still drawn.
///
/// The nock latch has exactly two authored writers: `$BWP` sets it, `$BWR` clears it, and both
/// tags live only in clips a STANDING unit plays (the Load pull and the fire clip; a real
/// character M2's Run authors neither — verified by dumping every playable model's event tracks).
/// So a latch carried into locomotion could never be cleared by anything, and the gait arm's
/// hold-pick re-latch (0409's INTERIM) re-arms it on every volley — the leak was guaranteed.
#[test]
fn a_running_shooter_drops_the_nocked_arrow_and_keeps_its_ammo_cache() {
let mut app = app();
let unit = app
.world_mut()
.spawn((
archer_model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::net::SelfPlayer,
Wielded {
ranged: Some((2, 0x2)), // bow
..Default::default()
},
crate::creature_anim::NockedAmmo { display_id: 5996 },
crate::creature_anim::NockLatch,
))
.id();
// The bow is drawn and the arrow is on the string: the steady state of a standing shooter.
app.world_mut().write_message(SheathRequest {
entity: unit,
state: 2,
ceremony: false,
});
app.update();
let latched = |app: &App| {
app.world()
.entity(unit)
.get::<crate::creature_anim::NockLatch>()
.is_some()
};
assert!(latched(&app), "standing drawn: the arrow stays nocked");
// They run.
app.world_mut().entity_mut(unit).insert(MovementState {
speed: 7.0,
flags: move_flags::FORWARD,
..Default::default()
});
app.update();
assert!(
!latched(&app),
"moving un-nocks: the arrow leaves the hand and the string relaxes"
);
assert!(
app.world()
.entity(unit)
.get::<crate::creature_anim::NockedAmmo>()
.is_some(),
"…but the ammo DISPLAY cache survives — the next pull re-nocks the same arrow without \
waiting on a fresh SMSG_SPELL_START"
);
}
/// The aiming half of the same report ("they keep aiming like they are going to shoot at
/// something"): the drawn Load/Hold idle is entered by the LOCAL auto-repeat bit `0x200` alone
/// (`0x5fd460`'s only claim test). The any-caster weapon-visual hold `0x400` — which every
/// ranged-slot spell's visual sets and nothing ever clears on volley end — must not admit it.
#[test]
fn the_weapon_visual_hold_alone_never_puts_a_shooter_in_the_drawn_idle() {
let spawn = |app: &mut App, auto_repeat: bool| {
let mut e = app.world_mut().spawn((
archer_model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::net::SelfPlayer,
Wielded {
ranged: Some((2, 0x2)),
..Default::default()
},
// Set by ANY ranged spell's visual play — one Multi-Shot is enough, and it is still
// set an hour later.
crate::creature_anim::RangedHold,
));
if auto_repeat {
e.insert(crate::creature_anim::AutoRepeatArmed);
}
e.id()
};
let mut app = app();
let shot_once = spawn(&mut app, false);
let shooting = spawn(&mut app, true);
for unit in [shot_once, shooting] {
app.world_mut().write_message(SheathRequest {
entity: unit,
state: 2,
ceremony: false,
});
}
app.update();
app.update();
let gait = |app: &App, e: Entity| app.world().entity(e).get::<AnimDriver>().unwrap().gait;
assert_eq!(
gait(&app, shot_once),
Some(0),
"a hunter who fired one Multi-Shot and stopped stands normally — the bow stays drawn \
(nothing stows on combat end), but they are not aiming it"
);
assert_eq!(
gait(&app, shooting),
Some(105),
"…while an actively auto-repeating shooter pulls the bow: the `0x200` entry"
);
}
@@ -576,6 +576,39 @@ pub(super) fn is_ranged_load(id: u16) -> bool {
matches!(id, 105 | 106 | 112)
}
/// Does the drawn ranged Load/Hold family own this unit's standing idle? The **entry/sustain
/// asymmetry**, byte-verified (wow-re `ranged-shot-anim.md` §b + `nocked-ammo-cancel.md` §Q-I-I0):
///
/// - **ENTRY is `[+0xd58] & 0x200` alone** — the resolver `0x5fd460` (tier 9 of `ComputeAnimation
/// 0x5fd8b0`) claims on `cmp [+0xd40],2` (sheath ranged) `&& test ah,0x2` and nothing else. That
/// bit's ONLY writer image-wide is the LOCAL cast-send `0x6e593b`, gated `AttributesEx2 & 0x20`
/// — so entry means *this client's player is actively auto-repeating*. Nothing else can start
/// the family: 109/110/111 are reachable only through their Load clip's completion
/// (`0x5fc3f0` slot 11 → `PlayAnimation(109)`), and the Load comes only from `0x5fd460`.
/// - **`0x400` ([`super::RangedHold`], the any-caster weapon-visual hold) only SUSTAINS** — it
/// appears in exactly one gait gate, HoldBow's self-loop at `0x5fc3f0` slot 13
/// (`[+0xd24] != 0 && (channel || [+0xd58] & 0x600)`), which re-plays a hold **already on the
/// base track**. It can never admit the family.
///
/// Folding `0x400` into the *entry* gate (which this codebase did, with the asymmetry knowingly
/// "unmodeled") is what made a single Multi-Shot / Arcane Shot — any ranged-slot spell whose
/// visual sets the hold bit — leave the shooter aiming a drawn bow forever, and left a REMOTE
/// shooter permanently drawn where the reference stands it up (§c's verified negative: a remote
/// unit never runs the local cast-send, so its standing pose is Stand). `held` is our
/// "the Load pass finished, the hold is what's playing" latch — the base-track state the
/// self-loop gate reads.
///
/// The caller applies the tier order: locomotion (tier 4) outranks tier 9, so a moving unit is
/// never in the idle whatever these bits say.
pub(super) fn ranged_idle_gate(
auto_repeat: bool,
ranged_hold: bool,
held: bool,
sheath_cur: Option<u8>,
) -> bool {
sheath_cur == Some(2) && (auto_repeat || (held && ranged_hold))
}
/// The victim **defense-reaction** id (decision 0279 — the `$CPP` dispatch, byte-verified
/// `0x624a01` → `0x60ec00`): DODGE/DEFLECTS → Dodge(30) · BLOCKS → ShieldBlock(24) · PARRY keys
/// the victim's own **mainhand** through the `0x60ec98` LUT (read off `WoW.exe` `.text`, its
@@ -464,6 +464,36 @@ fn ranged_load_idle_selects_by_weapon_and_ranks_below_ready() {
)));
}
/// The drawn ranged idle's **entry/sustain asymmetry** ([`ranged_idle_gate`], byte-verified):
/// `0x5fd460` claims on the local auto-repeat bit `0x200` ALONE, while the any-caster
/// weapon-visual hold `0x400` appears only in `0x5fc3f0`'s HoldBow self-loop — it sustains a hold
/// already playing and can never start one. Folding the two into one entry gate is the defect the
/// director reported on 2026-08-05 ("they keep aiming like they are going to shoot at something"):
/// a single Multi-Shot sets `0x400`, and nothing in the client ever clears it on volley end.
#[test]
fn the_ranged_idle_is_entered_by_auto_repeat_alone_and_only_sustained_by_the_hold_bit() {
// ENTRY: the local `0x200`, with the ranged sheath. This is the whole claim of `0x5fd460`.
assert!(ranged_idle_gate(true, false, false, Some(2)));
// …and it needs CUR == 2: a melee-drawn or stowed unit is never in the family.
assert!(!ranged_idle_gate(true, false, false, Some(1)));
assert!(!ranged_idle_gate(true, false, false, Some(0)));
assert!(!ranged_idle_gate(true, false, false, None));
// `0x400` ALONE never enters — the regression under the report. This is the state of a
// hunter who fired one Multi-Shot and stopped, and of every REMOTE shooter (which never
// runs the local cast-send, so `0x200` cannot exist on it — §c's verified negative).
assert!(!ranged_idle_gate(false, true, false, Some(2)));
// Nor does "no bits at all" with the stance still drawn.
assert!(!ranged_idle_gate(false, false, false, Some(2)));
// SUSTAIN: mid-volley the local shooter has both bits; the hold survives a frame where the
// auto-repeat bit is the only one to have dropped, exactly like 109's `& 0x600` self-loop.
assert!(ranged_idle_gate(true, true, true, Some(2)));
assert!(ranged_idle_gate(false, true, true, Some(2)));
// …and once BOTH bits are gone (the cancel `0x6ea080` clears them together), the hold ends.
assert!(!ranged_idle_gate(false, false, true, Some(2)));
}
#[test]
fn state_emote_idle_only_fills_the_bare_stand_slot() {
// Standing, nothing else going on: the one slot the state-emote idle may fill.
@@ -285,25 +285,33 @@ impl WeaponVisualSrc<'_, '_> {
/// A spell's effective `SpellVisual` row (spell → `Spell.dbc` column 115 → `SpellVisual.dbc`).
/// `None` = a silent cast (no visual chain at all).
///
/// The one place the client's **ranged weapon-visual fallback** applies (`0x60d450`, bytes
/// `60d465`–`60d4aa`; wow-re `throw-ranged-attack-anim.md`): a RANGED-attribute spell
/// (`Attributes & 0x2`) whose own visual resolves to nothing borrows `weapon_visual` — the
/// caster's equipped ranged weapon's substitute visual. Every basic shot rides this (they all
/// have `SpellVisual1 = 0`): Throw → ReadyThrown/AttackThrown, Auto Shot → LoadBow/AttackBow,
/// wand Shoot → the gun kits. A spell with its own visual never takes the fallback (the client's
/// `60d4b4` test), and a non-ranged spell never pays the lookup (`FnOnce`).
fn resolve_stages<'a>(
/// The one place the client's **ranged weapon-visual merge** applies (`0x60d450`; the law is
/// [`VisualStages::merged_over_weapon`], byte-read there): a RANGED-attribute spell
/// (`Attributes & 0x2`) takes the caster's equipped ranged weapon's substitute visual for every
/// slot its **own** row leaves at zero — precast/cast/impact kits, the missile block, the strike
/// sound; never state/channel. A basic shot (`SpellVisual1 = 0`) is only the degenerate case of
/// that fill — the whole zeroed row comes across (Throw → ReadyThrown/AttackThrown, Auto Shot →
/// LoadBow/AttackBow, wand Shoot → the gun kits), which is what decision 0370 read as the *whole*
/// mechanism; every hunter shot spell has its own impact + missile row and empty body-anim slots,
/// so it needs the per-field arm (decision 0986, bug B153). A non-ranged spell never pays the
/// lookup (`FnOnce`).
fn resolve_stages(
spells: &crate::ui_action::Spells,
visuals: &'a SpellVisualCatalog,
visuals: &SpellVisualCatalog,
spell_id: u32,
weapon_visual: impl FnOnce() -> Option<u32>,
) -> Option<&'a VisualStages> {
) -> Option<VisualStages> {
let def = spells.catalog.get(spell_id)?;
match visuals.stages(def.visual) {
own @ Some(_) => own,
None if def.ranged_slot() => visuals.stages(weapon_visual()?),
None => None,
let own = visuals.stages(def.visual).copied();
if !def.ranged_slot() {
return own; // `ebx` stayed 0 at `60d468` — the fill block is a no-op
}
// `60d46e`–`60d4aa`: the caster's ranged display → `ItemDisplayInfo` col 10 → its row.
let Some(weapon) = weapon_visual().and_then(|v| visuals.stages(v)) else {
return own;
};
// No own row = the client's `rep stosd` zeroed `outKit` before the same fill (`60d4bc`).
Some(own.unwrap_or_default().merged_over_weapon(weapon))
}
/// Resolve one lifecycle stage of `spell_id`'s visual chain to its kit (spell → `Spell.dbc`
@@ -317,7 +325,7 @@ fn resolve_kit(
stage: impl Fn(&VisualStages) -> u32,
weapon_visual: impl FnOnce() -> Option<u32>,
) -> Option<VisualKit> {
let kit_id = stage(resolve_stages(spells, visuals, spell_id, weapon_visual)?);
let kit_id = stage(&resolve_stages(spells, visuals, spell_id, weapon_visual)?);
(kit_id != 0)
.then(|| visuals.kit(kit_id).copied())
.flatten()
@@ -793,6 +801,12 @@ pub(super) fn route_cast_visuals(
let Some(display) = spells.catalog.get(go.spell_id) else {
continue;
};
// The caster's ranged weapon visual, resolved once per GO (the client's `0x6e802e` call
// into `0x60d450`) — its merged row `edi` is what EVERY consumer below reads: the dest
// one-shot (`[edi+0x18]`/`[edi+0x30]`), the cast kit (`[edi+0x8]`), the inline impacts
// (`6e8169 mov edx,edi`) and the missile spawn (`6e8199 mov edx,edi`).
let wv = weapon_src.caster(go.caster);
let stages = resolve_stages(spells, &visuals.0, go.spell_id, || wv);
// The GO's dest one-shot (decision 0797, byte-pinned `0x6e8088`–`0x6e8143`): a
// dest-carrying GO plays the SpellVisual field-12 model ONCE at the packet's point —
// gate `field 6 == 0` (no missile owns the arrival) ∧ `field 12 ≠ 0`. NOT gated on the
@@ -800,7 +814,7 @@ pub(super) fn route_cast_visuals(
// captured Flamestrike shape); fired here at the GO, never waiting on the dynobj
// create (wow-re trap #6: the burst precedes the object).
if let Some(dest) = go.dest {
if let Some(stages) = visuals.0.stages(display.visual) {
if let Some(stages) = stages {
if stages.missile_gate == 0 && stages.area_effect != 0 {
if let Some(path) = visuals.0.effect_path(stages.area_effect) {
bursts.write(crate::entities::dest_fx::GroundBurst {
@@ -811,9 +825,6 @@ pub(super) fn route_cast_visuals(
}
}
}
// The caster's ranged fallback visual, resolved once per GO (the client's `0x6e802e`
// call into `0x60d450`) — feeds the inline impacts, the missile chain, and the flight.
let wv = weapon_src.caster(go.caster);
if display.speed <= 0.0 {
for &target in &go.hits {
play_impact(
@@ -832,7 +843,6 @@ pub(super) fn route_cast_visuals(
// flies). The missile-model chain: field 7 ≥ 1 → its effect model (the client's
// literal ErrorCube when unresolvable); < 1 / no visual row → `None`, and the
// spawner falls to the GO's wire ammo model (`0x479f40`, phase 5).
let stages = resolve_stages(spells, &visuals.0, go.spell_id, || wv);
let path = stages.and_then(|s| {
(s.missile_model >= 1).then(|| {
visuals
@@ -859,9 +869,10 @@ pub(super) fn route_cast_visuals(
// The release gate (the client's `0x6e7a70` flush condition, inverted): a cast
// kit that plays a body animation defers the launch to its release keyframe;
// no kit / no anim (`kit+8 < 1` ⇔ our `anim_id: None`) launches at GO.
let awaits_release =
resolve_kit(spells, &visuals.0, go.spell_id, |s| s.cast, || wv)
.is_some_and(|k| k.anim_id.is_some());
let awaits_release = stages
.filter(|s| s.cast != 0)
.and_then(|s| visuals.0.kit(s.cast))
.is_some_and(|k| k.anim_id.is_some());
missiles.write(MissileSpawn {
caster: go.caster,
spell_id: go.spell_id,
@@ -338,16 +338,19 @@ fn same_frame_start_and_fail_leave_no_hold() {
assert_eq!(hold(&app, unit), None, "the failed cast's hold is released");
}
/// The ranged weapon-visual fallback (`0x60d450`, wow-re `throw-ranged-attack-anim.md`, decision
/// 0370), on [`super::resolve_stages`] directly: a RANGED-attribute spell (`Attributes & 0x2`)
/// whose own visual is null resolves through the caster's weapon visual; a non-ranged spell
/// never does; a spell with its own visual never takes the fallback.
/// The ranged weapon-visual MERGE (`0x60d450`, decision 0986 correcting 0370's row-level
/// reading), on [`super::resolve_stages`] directly: a RANGED-attribute spell (`Attributes & 0x2`)
/// fills every ZERO slot of its own row from the caster's weapon visual — so a basic shot with no
/// row at all takes the lot, a hunter shot keeps its impact/missile and gains the body kits, and a
/// non-ranged spell never looks.
#[test]
fn ranged_spells_fall_back_to_the_weapon_visual() {
fn ranged_spells_merge_the_weapon_visual_into_their_empty_slots() {
const THROW: u32 = 2764; // Attributes 0x410012, SpellVisual1 0 — the real Throw shape
const FIREBALL: u32 = 133; // its own visual; the fallback must stay unused
const MULTI_SHOT: u32 = 2643; // RANGED, own visual 567: impact + missile, no body kits
const NO_VIS_MELEE: u32 = 772; // no visual, no RANGED attribute — stays silent
const WEAPON_VISUAL: u32 = 98; // the real thrown ItemDisplayInfo col-10 substitute
const MULTI_SHOT_VISUAL: u32 = 567;
let visuals = SpellVisualCatalog::from_tables(
HashMap::from([
@@ -366,6 +369,18 @@ fn ranged_spells_fall_back_to_the_weapon_visual() {
..Default::default()
},
),
(
// The real 5875 row: impact kit 658, missile model 528, gate 1, attach 1 — and
// both body-kit slots empty, which is the whole of B153.
MULTI_SHOT_VISUAL,
VisualStages {
impact: 658,
missile_gate: 1,
missile_model: 528,
missile_attach: 1,
..Default::default()
},
),
]),
HashMap::new(),
);
@@ -387,6 +402,14 @@ fn ranged_spells_fall_back_to_the_weapon_visual() {
..Default::default()
},
),
(
MULTI_SHOT,
SpellDisplay {
visual: MULTI_SHOT_VISUAL,
attributes: 0x10002, // the real word: RANGED set, own visual present
..Default::default()
},
),
(
NO_VIS_MELEE,
SpellDisplay {
@@ -413,7 +436,24 @@ fn ranged_spells_fall_back_to_the_weapon_visual() {
super::resolve_stages(&spells, &visuals, FIREBALL, || Some(WEAPON_VISUAL))
.map(|s| s.precast),
Some(PRECAST_KIT),
"an own visual is never displaced by the fallback"
"a populated slot is never displaced by the weapon's"
);
let multi = super::resolve_stages(&spells, &visuals, MULTI_SHOT, || Some(WEAPON_VISUAL))
.expect("Multi-Shot has its own row");
assert_eq!(
(multi.precast, multi.cast),
(171, 172),
"the empty body-kit slots fill from the weapon — the missing draw/release (B153)"
);
assert_eq!(
(multi.impact, multi.missile_model, multi.missile_attach),
(658, 528, 1),
"its own impact + missile block survives the merge untouched"
);
assert_eq!(
super::resolve_stages(&spells, &visuals, MULTI_SHOT, || None).map(|s| (s.cast, s.impact)),
Some((0, 658)),
"no ranged weapon equipped → the own row stands alone, unfilled"
);
assert!(
super::resolve_stages(&spells, &visuals, NO_VIS_MELEE, || Some(WEAPON_VISUAL)).is_none(),
+1
View File
@@ -40,6 +40,7 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
CursorPayload::Item(i) => i.texture,
CursorPayload::Spell(s) => s.texture,
CursorPayload::Action(a) => a.texture,
CursorPayload::Macro(m) => m.texture,
}
}
@@ -773,6 +773,9 @@ pub(super) fn attach_entity_visuals(
// (appear ramp, stream-out ramp, the self-avatar feather) multiplies
// its clouds — decision 0827.
alpha: Some(entity),
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// The emitters' rate/enabled read this instance's PLAYING sequence — a
// unit's or GameObject's `AnimationPlayer` on the root. A quest object's
@@ -611,6 +611,9 @@ fn spawn_slot(
// (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it
// and vanishes with the avatar in first person (0827/0833).
alpha: Some(root),
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// A held item spawns no rig; its emitters run the item model's own slot-0
// loop on the spawn clock (the torch burns always — the doodad law).
@@ -600,6 +600,12 @@ pub(super) fn attach_effect_visuals(
// This instance IS the model these particles belong to; its chain (set above)
// carries the host's fade down to them — decision 0833.
alpha: Some(root),
// The ride-vs-trail baseline (0986): an UNATTACHED instance is a free world model
// whose own transform IS its world placement, so its motion reaches the particles
// through the emitter matrix and each one hangs where it was born — the hunter
// arrow's bead trail. An attached one is carried by the model it hangs on (the
// reference's device-stack `S`), and its cloud rides.
world_composed: !host.attached,
},
// The emitters' rate/enabled windows ride the played sequence: a `CEffect` ADVANCES
// (`Stand` → `Hold` → `Decay`), so it reads the live one off its own player like a
+3
View File
@@ -340,6 +340,9 @@ pub(super) fn spawn_wmo_gameobject_props(
// A placed prop carries no fade component of its own; its emitters
// take the doodad distance fade in the sim instead (0827).
alpha: None,
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// A placed prop: the doodad law — which is NOT one arm for life. It re-rolls
// its variation every play-window (decision 0768), so the emitter resolves
+45
View File
@@ -50,6 +50,36 @@ pub(crate) fn config_path() -> Option<PathBuf> {
home().map(|h| h.join("config.toml"))
}
/// `benilla/macros/account.txt` — the account-wide macro tab (indices 1..=18; decision 0983). One
/// file per scope rather than one file with two sections, because the two have different lifetimes:
/// the account tab follows the install, a character tab dies with its character.
pub(crate) fn macros_account_path() -> Option<PathBuf> {
home().map(|h| h.join("macros/account.txt"))
}
/// `benilla/macros/<realm>-<character>.txt` — the per-character macro tab (indices 19..=36). The
/// reference nests these (`WTF/Account/<ACC>/<REALM>/<CHAR>/macros-cache.txt`); benilla flattens to
/// one `macros/` folder because [`home`] is already account-scoped by the install it sits beside.
pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("macros").join(format!("{key}.txt")))
}
/// Make an arbitrary realm/character name safe as one path component: anything outside
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
/// escape the folder or collide with the path separator.
fn file_token(s: &str) -> String {
let t: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if t.is_empty() {
"unknown".into()
} else {
t
}
}
/// Write a state file atomically: parent dirs created, contents to `<path>.tmp`, then rename —
/// a crash mid-write leaves the old file intact, never a truncated one. The one write path for
/// every resident of the folder.
@@ -114,6 +144,21 @@ mod tests {
let _d = EnvGuard::set("WOW_DATA", tmp.join("Data").to_str().unwrap());
assert_eq!(home(), Some(tmp.join("benilla")));
assert_eq!(config_path(), Some(tmp.join("benilla/config.toml")));
// The macro residents (decision 0983): one account file, one per character, and every
// realm/character name reduced to a safe single path component.
assert_eq!(
macros_account_path(),
Some(tmp.join("benilla/macros/account.txt"))
);
assert_eq!(
macros_character_path("Hydraxian Waterlords", "Probeone"),
Some(tmp.join("benilla/macros/Hydraxian_Waterlords-Probeone.txt"))
);
assert_eq!(
macros_character_path("../evil", "a/b"),
Some(tmp.join("benilla/macros/___evil-a_b.txt")),
"no name can escape the folder"
);
// 1 · the explicit override wins over the install root.
let _h2 = EnvGuard::set("BENILLA_HOME", tmp.join("elsewhere").to_str().unwrap());
+12
View File
@@ -120,12 +120,14 @@ mod ui_items;
mod ui_logout;
mod ui_loot;
mod ui_loot_roll;
mod ui_macro;
mod ui_mail;
mod ui_merchant;
mod ui_mirror;
mod ui_net;
mod ui_party;
mod ui_pass;
mod ui_pet;
mod ui_quest;
mod ui_quest_log;
mod ui_script;
@@ -209,6 +211,7 @@ use ui_mirror::UiMirrorPlugin;
use ui_net::UiNetPlugin;
use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
use ui_pet::UiPetPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_script::UiScriptPlugin;
@@ -660,6 +663,11 @@ fn main() -> AppExit {
// arc (bags/doll/bars/book). After UiActionPlugin (shares its `Spells` resource + the cast
// tail `send_spell_cast`).
.add_plugins(UiSpellbookPlugin)
// The macro system (decision 0983): the icon chooser's catalog, the `benilla/macros/`
// files, `UPDATE_MACROS`, and the macro→bound-spell table the action bar's MACRO slots
// resolve their cooldown/usability through. After UiSpellbookPlugin — the bound spell is
// resolved against the book that feed pushes, by the same law `CastSpellByName` uses.
.add_plugins(ui_macro::UiMacroPlugin)
// The talent window feed (decision 0304): builds the class pages from Talent.dbc × the
// known-spell set + PLAYER_CHARACTER_POINTS, drives TalentFrame.xml through the engine's
// talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin
@@ -670,6 +678,10 @@ fn main() -> AppExit {
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
// UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail).
.add_plugins(UiShapeshiftPlugin)
// The pet action bar (decision 0982) — the stance bar's mirror image: server-authoritative,
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
.add_plugins(UiPetPlugin)
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
// bar's performance meter polls (decision 0658).
.add_plugins(UiNetPlugin)
+27
View File
@@ -669,6 +669,33 @@ pub(crate) enum ClientCommand {
/// engine's own `kind<<24 | action` word. Sent by the action drain on every queued
/// `PickupAction`/`PlaceAction` mutation — client-authoritative, no answer packet.
SetActionButton { button: u8, packed: u32 },
/// Press one pet bar slot (`CMSG_PET_ACTION`, decisions 0982/0988). `packed` is the slot's OWN
/// word as the server last sent it — command, reaction and spell all ride this one command,
/// because the server dispatches on the type byte inside the word. `target_guid` is the
/// player's current selection, which is what the client always sends (wow-re §10.1).
///
/// **Nothing answers it.** Unlike [`Self::SetActionButton`], whose silence is because the
/// state is ours, this one is silent because the *server* simply does not reply — so the
/// caller has already applied the visible change locally before queueing it.
PetAction {
pet_guid: u64,
packed: u32,
target_guid: u64,
},
/// Write pet bar slots (`CMSG_PET_SET_ACTION`, decision 0988) — `(0-based position, the whole
/// new word)` pairs, one or two.
///
/// **This is how the autocast toggle travels**, with one entry: the client flips bit 30 in the
/// slot's word and posts the result, and the server reads the direction back out of the type
/// byte it arrives in. `CMSG_PET_SPELL_AUTOCAST` (0x2F3) is a *different* binding's opcode —
/// the pet spellbook's `ToggleSpellAutocast`, which indexes the spellbook rather than the bar
/// and which we do not ship. The same send carries the drag when that lands.
PetSetAction {
pet_guid: u64,
entries: Vec<(u32, u32)>,
},
/// Call the pet off its target (`CMSG_PET_STOP_ATTACK`) — the Attack button's second press.
PetStopAttack { pet_guid: u64 },
/// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`); echoed as `SMSG_ATTACKSTART`.
AttackSwing { guid: u64 },
/// Stop melee auto-attack (`CMSG_ATTACKSTOP`); echoed as `SMSG_ATTACKSTOP`, whose receive path
+70 -10
View File
@@ -27,6 +27,7 @@ mod mount;
mod names;
mod npc;
mod objects;
mod pet;
mod quests;
mod session;
mod spells;
@@ -52,6 +53,33 @@ use spells::{
superceded_spell,
};
/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982).
///
/// All four of them (`SMSG_SPELL_COOLDOWN`, `_COOLDOWN_EVENT`, `_CLEAR_COOLDOWN`,
/// `_COOLDOWN_CHEAT`) carry a caster guid, and until the pet bar existed all four answered it the
/// same way: "is it us? then apply, else drop" — four copies of a self-only assumption, each
/// inside its own arm. Since the server sends a pet's cooldowns on the pet's guid, that
/// assumption silently discarded every one of them. Resolving the guid ONCE, here, is what let the
/// pet bar sweep for real without a second copy of any arm; it also matches the reference, whose
/// `SMSG_COOLDOWN_CHEAT` handler wipes "the self/pet cooldown list" off exactly this test.
///
/// `None` = a guid we hold no store for (another player's pet, a stale packet): dropped, as the
/// client drops an unknown guid.
fn addressed_store<'a>(
caster: u64,
self_guid: &SelfGuid,
player: &'a mut crate::cooldowns::Cooldowns,
pet: &'a mut crate::ui_pet::PetBar,
) -> Option<&'a mut crate::cooldowns::Cooldowns> {
if self_guid.0 == Some(caster) {
Some(player)
} else if pet.has_bar() && pet.spells.pet_guid == caster {
Some(&mut pet.cooldowns)
} else {
None
}
}
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
/// Drain the inbound event channel and mutate real ECS entities: spawn on create, move existing,
@@ -149,6 +177,12 @@ pub(super) fn apply_net_updates(
// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges,
// drained into the FrameXML bars by `crate::ui_mirror`.
ResMut<crate::ui_mirror::MirrorTimerFeed>,
// The pet action bar's server-authoritative state + its own cooldown store
// (decision 0982), replaced wholesale on every `SMSG_PET_SPELLS`; and the by-key red
// error queue its refused-order feedback rides (the `DisplayError` route, resolved
// through the VM's own GlobalStrings by `ui_action::feed_actions`).
ResMut<crate::ui_pet::PetBar>,
ResMut<crate::ui_action::UiErrorKeys>,
),
),
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
@@ -290,6 +324,8 @@ pub(super) fn apply_net_updates(
area_table,
exploration_sounds,
mut mirror_timers,
mut pet_bar,
mut ui_error_keys,
),
) = caches;
let (
@@ -1037,13 +1073,13 @@ pub(super) fn apply_net_updates(
&mut commands,
&net_commands,
),
SessionEvent::SpellCooldowns { caster, cooldowns } => spell_cooldowns(
caster,
cooldowns,
&self_guid,
ui_actions.11.as_deref(),
&mut ui_actions.10,
),
SessionEvent::SpellCooldowns { caster, cooldowns } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
spell_cooldowns(caster, cooldowns, ui_actions.11.as_deref(), store);
}
}
SessionEvent::ItemCooldown {
item_guid,
spell_id,
@@ -1056,13 +1092,37 @@ pub(super) fn apply_net_updates(
seconds,
} => items.set_enchant_deadline(item_guid, slot, seconds),
SessionEvent::CooldownEvent { spell_id, caster } => {
cooldown_event(spell_id, caster, &self_guid, &mut ui_actions.10)
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
cooldown_event(spell_id, caster, store);
}
}
SessionEvent::ClearCooldown { spell_id, caster } => {
clear_cooldown(spell_id, caster, &self_guid, &mut ui_actions.10)
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
clear_cooldown(spell_id, caster, store);
}
}
SessionEvent::CooldownCheat { caster } => {
cooldown_cheat(caster, &self_guid, &mut ui_actions.10)
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
cooldown_cheat(caster, store);
}
}
// The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a
// wholesale replace and its zero-guid form is the teardown.
SessionEvent::PetSpells(spells) => {
pet::pet_spells(*spells, ui_actions.11.as_deref(), &mut pet_bar)
}
SessionEvent::PetMode(mode) => pet::pet_mode(mode, &mut pet_bar),
SessionEvent::PetActionFeedback { reason } => {
pet::pet_action_feedback(reason, &mut ui_error_keys)
}
SessionEvent::PetCastFailed { spell_id, reason } => {
pet::pet_cast_failed(spell_id, reason, &mut ui_actions.1 .0)
}
SessionEvent::ChannelStart {
spell_id,
+232
View File
@@ -0,0 +1,232 @@
//! The pet bar's wire arms (decisions 0982, 0988) — `SMSG_PET_SPELLS`, `SMSG_PET_MODE`,
//! `SMSG_PET_ACTION_FEEDBACK`, `SMSG_PET_CAST_FAILED` folded into [`crate::ui_pet::PetBar`].
//!
//! The whole file is short because `SMSG_PET_SPELLS` is not a delta — it **is** the bar, so
//! applying it is a replace. These four arms own the bar's CONTENTS; its lit state is
//! [`crate::ui_pet`]'s, latched on the press because the server never answers one (that file's
//! module doc has the why). The only state this file writes is what actually arrives on the wire.
//!
//! The two refusal arms deliberately reuse the *player's* red-line queues rather than growing pet
//! copies: both resolve their text through the VM's own `GlobalStrings.lua` the same way, and the
//! cast-fail resolver keys its power family on the failing **spell's** record ([`cast_fail`]'s
//! `power_keys(spell.power_type)`), so a pet's focus ability already reads "Not enough focus"
//! with no pet-awareness anywhere in the display layer.
//!
//! [`cast_fail`]: crate::ui_action
use std::time::Instant;
use bevy::prelude::*;
use benilla_protocol::messages::{PetMode, PetSpells};
use crate::ui_action::{CastErrors, Spells, UiError, UiErrorKeys};
use crate::ui_pet::PetBar;
/// `SMSG_PET_SPELLS` — replace the whole bar, and reseed the pet's own cooldown store from the
/// packet's tail.
///
/// A **zero guid** is the teardown (`Player::RemovePetActionBar`): the bar goes away and the
/// cooldown store goes with it, because the next pet is a different unit with different timers.
/// Everything else is a wholesale replace — including a re-send from the same pet, which is how a
/// learned spell, a mode change or an autocast toggle actually reaches the bar.
pub(super) fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
if spells.pet_guid == 0 {
if bar.spells.pet_guid != 0 {
debug!("net: pet bar torn down");
}
*bar = PetBar::default();
return;
}
// A pet-GUID CHANGE clears the attack latch (`0x4bc8ce`, the client's own single writer of the
// pet guid does it unconditionally) — a freshly summoned pet is not attacking, whatever the
// last one was doing. A re-send from the SAME pet leaves it alone: a learned spell must not
// silently call the pet off.
if bar.spells.pet_guid != spells.pet_guid {
bar.attacking = false;
}
debug!(
"net: pet bar for {:#x} — react {} command {} {}, {} known spell(s), {} cooldown(s)",
spells.pet_guid,
spells.react_state(),
spells.command_state(),
if spells.bar_disabled() {
"DISABLED"
} else {
"usable"
},
spells.spells.len(),
spells.cooldowns.len(),
);
// The ten words, spelled out. Worth its own line rather than a raw hex dump: every question
// this packet raises ("why is that slot empty", "why is nothing lit", "is that spell in the
// catalog") is answered by seeing the type/action pair beside the name we resolved for it, and
// reading that off `0xc1000bc2` by hand is exactly the step nobody does.
debug!(
"net: pet bar slots — {}",
spells
.bar
.iter()
.enumerate()
.map(|(i, e)| {
let what = match e.kind() {
benilla_protocol::messages::PET_ACT_COMMAND => format!("cmd {}", e.action()),
benilla_protocol::messages::PET_ACT_REACTION => format!("react {}", e.action()),
_ if e.is_empty() => "empty".to_string(),
_ => match catalog.and_then(|c| c.catalog.get(e.action())) {
Some(d) => format!("{} ({})", d.name, e.action()),
None => format!("spell {} NOT IN CATALOG", e.action()),
},
};
format!("{}:{what}/{:#04x}", i + 1, e.kind())
})
.collect::<Vec<_>>()
.join(" ")
);
let now = Instant::now();
bar.cooldowns = crate::cooldowns::Cooldowns::default();
for cd in &spells.cooldowns {
let display = catalog.and_then(|c| c.catalog.get(cd.spell_id));
bar.cooldowns.seed_pet(cd, display, now);
}
bar.spells = spells;
}
/// `SMSG_PET_MODE` — the react/command state alone. Applied only when it names the pet whose bar
/// we actually hold: a mode packet for a unit we have no bar for has nothing to write into, and
/// taking its state anyway would light a reaction button on the wrong pet's bar.
pub(super) fn pet_mode(mode: PetMode, bar: &mut PetBar) {
if bar.spells.pet_guid == 0 || bar.spells.pet_guid != mode.pet_guid {
return;
}
debug!("net: pet mode — state {:#010x}", mode.state);
// The whole dword, verbatim — the client stores this packet and `SMSG_PET_SPELLS`' state field
// through the same writer (`0x4bc930`), and bit 27 can only ever arrive this way.
bar.spells.state = mode.state;
}
/// `SMSG_PET_ACTION_FEEDBACK` — one reason byte for a refused order, queued onto the red line by
/// GlobalStrings key ([`UiErrorKeys`], the `DisplayError` route). An unrecognised code queues
/// nothing, exactly as an absent key shows nothing.
pub(super) fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) {
debug!("net: pet action feedback {reason}");
if let Some(key) = pet_feedback_key(reason) {
errors.0.push(UiError::key(key));
}
}
/// The `SMSG_PET_ACTION_FEEDBACK` reason → its `GlobalStrings.lua` key.
///
/// vmangos sends exactly two (`Unit::SendPetActionFeedback`'s call sites): `1` when the pet cannot
/// path to the ordered spot, `2` when the ordered target is out of its reach. Both keys ship in
/// 1.12's `GlobalStrings.lua` (`PET_SPELL_NOPATH` at l.3054, `SPELL_FAILED_OUT_OF_RANGE`), so the
/// text — and its localization — comes from the VM like every other error line.
fn pet_feedback_key(reason: u8) -> Option<&'static str> {
Some(match reason {
1 => "PET_SPELL_NOPATH",
2 => "SPELL_FAILED_OUT_OF_RANGE",
_ => return None,
})
}
/// `SMSG_PET_CAST_FAILED` — the pet's cast refusal, queued onto the SAME red line as our own
/// through [`CastErrors`], because the resolver is spell-keyed and needs no pet-awareness.
///
/// What it deliberately does NOT do is touch our cast state: the caster is the pet, so there is no
/// pending cast of ours to revert, no GCD of ours to clear and no button of ours to unflash — the
/// three things `cast_result`'s own failure path does. Reusing that path would have made a pet's
/// refused Growl cancel the player's cast bar.
pub(super) fn pet_cast_failed(spell_id: u32, reason: Option<u8>, errors: &mut CastErrors) {
debug!("net: pet cast failed — spell {spell_id} reason {reason:?}");
if let Some(reason) = reason {
errors.0.push((spell_id, reason));
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{PetActionEntry, PET_ACT_COMMAND, PET_COMMAND_ATTACK};
fn a_bar(guid: u64) -> PetSpells {
let mut s = PetSpells {
pet_guid: guid,
state: 0x0101, // react Defensive, command Follow
..Default::default()
};
s.bar[0] = PetActionEntry::from(PET_COMMAND_ATTACK | (u32::from(PET_ACT_COMMAND) << 24));
s
}
/// The teardown clears the bar AND its cooldown store — the next pet is a different unit, so
/// carrying its predecessor's timers would sweep the new pet's buttons for no reason.
#[test]
fn a_zero_guid_tears_the_whole_bar_down() {
let mut bar = PetBar::default();
pet_spells(a_bar(0x2A), None, &mut bar);
assert_eq!(bar.spells.pet_guid, 0x2A);
pet_spells(PetSpells::default(), None, &mut bar);
assert_eq!(bar.spells, PetSpells::default());
assert!(!bar.has_bar());
}
/// A mode packet for a DIFFERENT unit is dropped — it would otherwise light a reaction button
/// on the bar we are holding for somebody else's pet.
#[test]
fn pet_mode_only_writes_its_own_bar() {
let mut bar = PetBar::default();
pet_spells(a_bar(0x2A), None, &mut bar);
pet_mode(
PetMode {
pet_guid: 0x99,
state: 0x0202,
},
&mut bar,
);
assert_eq!(bar.spells.react_state(), 1, "a stranger's mode is ignored");
pet_mode(
PetMode {
pet_guid: 0x2A,
state: 0x0202,
},
&mut bar,
);
assert_eq!(bar.spells.react_state(), 2);
assert_eq!(bar.spells.command_state(), 2);
// The bar itself is untouched — PET_MODE carries no slots.
assert_eq!(bar.spells.bar[0].action(), PET_COMMAND_ATTACK);
}
/// The attack latch is cleared by a pet CHANGE and survives a re-send from the same pet — the
/// client's own single pet-guid writer does exactly this (`0x4bc8ce`). A learned spell must
/// not silently call the pet off; a new pet must not inherit the last one's attack.
#[test]
fn the_attack_latch_survives_a_resend_and_dies_on_a_new_pet() {
let mut bar = PetBar::default();
pet_spells(a_bar(0x2A), None, &mut bar);
bar.attacking = true;
pet_spells(a_bar(0x2A), None, &mut bar);
assert!(bar.attacking, "the same pet re-sending keeps its attack");
pet_spells(a_bar(0x99), None, &mut bar);
assert!(!bar.attacking, "a different pet is not attacking");
}
/// Only the two codes vmangos actually sends resolve; anything else draws nothing rather than
/// putting a bare number on the red line.
#[test]
fn only_the_shipped_feedback_codes_resolve() {
let mut errors = UiErrorKeys::default();
for reason in [0u8, 1, 2, 3, 200] {
pet_action_feedback(reason, &mut errors);
}
assert_eq!(
errors.0.iter().map(|e| e.key).collect::<Vec<_>>(),
["PET_SPELL_NOPATH", "SPELL_FAILED_OUT_OF_RANGE"]
);
}
}
+36 -40
View File
@@ -103,10 +103,14 @@ pub(super) fn cast_result(
// `nocked-ammo-cancel.md`): iff the failing spellId == the cached auto-repeat spell AND
// reason ≠ 0x17, the handler jumps into `0x6ea080` — the SAME routine the
// SMSG_CANCEL_AUTO_REPEAT handler runs — clearing the key, the shooting-idle bits, and
// the nocked ammo. Against vmangos this path is the ONLY live disarm: the server never
// sends SMSG_CANCEL_AUTO_REPEAT (dead packet class, zero send sites); a
// deselect/interrupt surfaces solely as this CAST_RESULT failure
// the nocked ammo. A deselect/interrupt surfaces as this CAST_RESULT failure
// (`HandleSetSelectionOpcode` → `Spell::cancel` → `SendCastResult(INTERRUPTED)`).
// **Correction (2026-08-05):** this is NOT the only live disarm — the long-standing note
// here that "vmangos never sends SMSG_CANCEL_AUTO_REPEAT (dead packet class, zero send
// sites)" is FALSE. `SpellCaster::InterruptSpell` (vmangos `SpellCaster.cpp:1826`) calls
// `Player::SendAutoRepeatCancel()` for every player autorepeat interrupt — target death
// included (`Unit::_UpdateAutoRepeatSpell` → `CheckCast` fails → `InterruptSpell`) — so
// [`cancel_auto_repeat`]'s handler is live, not dormant.
let now = Instant::now();
// Reason 0x17 DONT_REPORT exits the ref's handler at a bare epilogue BEFORE the GCD
// clear and the display (`6e1ce1`/`6e1cf7 → 0x6e224f` — 0948's C4): a silent server
@@ -575,6 +579,14 @@ pub(super) fn spell_delayed(
/// `[+0xd58] & 0x200` idle bit (0131 recorded "no clearing writer") or the hold-pose layer
/// merely makes the ref look still is the dispatched question; the verdict corrects this
/// if the mechanism differs.
///
/// **Live against vmangos, not dormant (corrected 2026-08-05).** The prior note here — "vmangos
/// never sends this" — was wrong: `SpellCaster::InterruptSpell` (`SpellCaster.cpp:1826`) sends it
/// on every player autorepeat interrupt, which is how **target death** stops a volley
/// (`Unit::_UpdateAutoRepeatSpell` → `CheckCast(true)` returns dead/bad-targets →
/// `InterruptSpell(CURRENT_AUTOREPEAT_SPELL)` → `SendAutoRepeatCancel`). Movement does NOT
/// (`_UpdateAutoRepeatSpell` interrupts only Category 351, the wand, when moving) — Auto Shot
/// stays armed across a run, exactly like vanilla.
pub(super) fn cancel_auto_repeat(
auto_repeat: &mut AutoRepeatActive,
self_guid: &SelfGuid,
@@ -584,19 +596,19 @@ pub(super) fn cancel_auto_repeat(
) {
debug!("net: cancel auto-repeat");
// The packet thunk `0x6e99d0` funnels into the same cancel `0x6ea080` as every local
// trigger — including its CMSG ack (unconditional inside the routine). vmangos never sends
// this SMSG, so against it the arm is dormant; kept for wire fidelity.
// trigger — including its CMSG ack (unconditional inside the routine). Live against vmangos,
// not dormant: the doc block above has the send site and the target-death path to it.
let self_e = self_guid.0.and_then(|g| index.0.get(&g)).copied();
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, commands, net);
}
/// `SMSG_SPELL_COOLDOWN` (`0x6e9460`) — server-pushed cooldowns for the player (school lockouts;
/// the pet list has no benilla consumer yet, so a pet-guid packet for a pet we don't model is
/// dropped like the client drops unknown guids).
/// `SMSG_SPELL_COOLDOWN` (`0x6e9460`) — server-pushed cooldowns (school lockouts, and the pet's
/// own list). **Which unit's store** it lands in is the caller's decision
/// ([`super::addressed_store`]): the four cooldown packets all carry a caster guid, and resolving
/// it in one place is what let the pet bar have real cooldowns without a second copy of this arm.
pub(super) fn spell_cooldowns(
caster: u64,
pairs: Vec<(u32, u32)>,
self_guid: &SelfGuid,
spells: Option<&Spells>,
cooldowns: &mut Cooldowns,
) {
@@ -604,9 +616,6 @@ pub(super) fn spell_cooldowns(
"net: spell cooldowns for {caster:#x} — {} pair(s)",
pairs.len()
);
if self_guid.0 != Some(caster) {
return;
}
let now = Instant::now();
for (spell_id, cooldown_ms) in pairs {
let display = spells.and_then(|s| s.catalog.get(spell_id));
@@ -629,38 +638,24 @@ pub(super) fn item_cooldown(
}
/// `SMSG_COOLDOWN_EVENT` (`0x6e9670` → `0x6e3050(force=0)`) — start an on-hold cooldown's parked
/// timers now (Stealth ends, Feign Death drops).
pub(super) fn cooldown_event(
spell_id: u32,
caster: u64,
self_guid: &SelfGuid,
cooldowns: &mut Cooldowns,
) {
/// timers now (Stealth ends, Feign Death drops). Store chosen by [`super::addressed_store`].
pub(super) fn cooldown_event(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) {
debug!("net: cooldown event — spell {spell_id} on {caster:#x}");
if self_guid.0 == Some(caster) {
cooldowns.cooldown_event(spell_id, Instant::now());
}
cooldowns.cooldown_event(spell_id, Instant::now());
}
/// `SMSG_CLEAR_COOLDOWN` (`0x6e9670` → `0x6e3050(force=1)`) — remove the spell's record outright.
pub(super) fn clear_cooldown(
spell_id: u32,
caster: u64,
self_guid: &SelfGuid,
cooldowns: &mut Cooldowns,
) {
pub(super) fn clear_cooldown(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) {
debug!("net: clear cooldown — spell {spell_id} on {caster:#x}");
if self_guid.0 == Some(caster) {
cooldowns.clear_spell(spell_id);
}
cooldowns.clear_spell(spell_id);
}
/// `SMSG_COOLDOWN_CHEAT` (`0x6e9730` → `0x6e9700`) — the GM reset wipes the whole list.
pub(super) fn cooldown_cheat(caster: u64, self_guid: &SelfGuid, cooldowns: &mut Cooldowns) {
/// `SMSG_COOLDOWN_CHEAT` (`0x6e9730` → `0x6e9700`) — the GM reset wipes the whole list. The
/// reference's own handler wipes "the self/pet cooldown list" on a guid match, which is exactly
/// what routing through [`super::addressed_store`] now reproduces.
pub(super) fn cooldown_cheat(caster: u64, cooldowns: &mut Cooldowns) {
debug!("net: cooldown cheat (wipe) for {caster:#x}");
if self_guid.0 == Some(caster) {
cooldowns.wipe();
}
cooldowns.wipe();
}
/// `MSG_CHANNEL_START` — self-only on the wire (no guid), so it goes straight to the cast bar; the
@@ -1034,10 +1029,11 @@ mod tests {
}
/// The director's stuck-shooting-idle report: click off the target during Auto Shot and the
/// Load/Hold stance never drops. vmangos never sends `SMSG_CANCEL_AUTO_REPEAT` (dead packet
/// class) — a deselect surfaces ONLY as a `SMSG_CAST_RESULT` failure for the cached
/// auto-repeat spell, and the client's `6e1cd9` jump into `0x6ea080` makes that the full
/// local cancel: the key AND the shooting idle both drop.
/// Load/Hold stance never drops. A deselect surfaces as a `SMSG_CAST_RESULT` failure for the
/// cached auto-repeat spell (vmangos `HandleSetSelectionOpcode` → `Spell::cancel` →
/// `SendCastResult(INTERRUPTED)`), and the client's `6e1cd9` jump into `0x6ea080` makes that
/// the full local cancel: the key AND the shooting idle both drop. (It is not the only live
/// disarm — see [`super::cancel_auto_repeat`]: vmangos does send `SMSG_CANCEL_AUTO_REPEAT`.)
#[test]
fn a_cast_result_fail_of_the_cached_auto_repeat_spell_disarms_the_shooting_idle() {
use crate::creature_anim::AutoRepeatArmed;
+9
View File
@@ -697,6 +697,15 @@ fn writer_loop(
ClientCommand::SetActionButton { button, packed } => {
w.set_action_button(button, packed)
}
ClientCommand::PetAction {
pet_guid,
packed,
target_guid,
} => w.pet_action(pet_guid, packed, target_guid),
ClientCommand::PetSetAction { pet_guid, entries } => {
w.pet_set_action(pet_guid, &entries)
}
ClientCommand::PetStopAttack { pet_guid } => w.pet_stop_attack(pet_guid),
ClientCommand::AttackSwing { guid } => w.attack_swing(guid),
ClientCommand::AttackStop => w.attack_stop(),
ClientCommand::SetSheathed { state } => w.set_sheathed(state),
+24 -4
View File
@@ -167,11 +167,26 @@ pub struct ParticleEmitter {
/// NOT the emitter's bone joint. The joint composes each particle's birth (position and
/// rotation baked, the reference's birth transforms) and then must never move the cloud
/// again: an emitter riding an ANIMATED bone — the food sparkle orbits on a global-sequence
/// spin — would otherwise drag every risen star in a circle (the director's swirl). File
/// flag 0x4000 is the client's only live-particle-follows-emitter plumbing and placed/spell
/// content doesn't author it. `None` = anchor at the spawn placement (a placed doodad whose
/// emitter rides a joint anchors at the doodad, not the bone).
/// spin — would otherwise drag every risen star in a circle (the director's swirl).
/// `None` = anchor at the spawn placement (a placed doodad whose emitter rides a joint
/// anchors at the doodad, not the bone). [`Self::world_composed`] is what decides whether the
/// anchor's own motion carries the pool at all.
anchor: Option<Entity>,
/// Whether this emitter's world MOTION reaches the particles through its own **emitter
/// matrix** rather than through the model's device-stack transform — the reference's real
/// ride-vs-trail discriminator (wow-re `part-emitter-motion.md` §2b: "`rt+0x1fc` local, Δ≈0 —
/// creature-attached doodads" ⇒ ride, vs "folded into `rt+0x1fc` … a translating missile
/// whose own model IS the emitter" ⇒ the birth bakes world-absolute and the particle is
/// world-FROZEN at draw). Bit 0x100 is NOT that switch and neither is the follow flag: the
/// kobold's candle (file `0x01`) rides while Multi-Shot's FLARE emitters (file `0x0309`,
/// equally unflagged) hang in the air behind the arrow.
///
/// `true` for a FREE world model — a missile, a planted ground burst — whose own transform is
/// its world placement; `false` for everything hung off a model that the scene graph moves (a
/// creature's own emitters, a kit effect on a unit, a held item's glow). It sets the
/// **baseline** the follow-delta term is measured against: 0 (world-frozen) here, 1 (rigid
/// ride) otherwise — see the follow block in [`sim`](crate::particles::sim). Decision 0986.
world_composed: bool,
/// The anchor's last-known world translation (kept when the entity vanishes so the pool
/// drains in place; init = the spawn placement's translation).
anchor_pos: Vec3,
@@ -313,6 +328,10 @@ pub struct EmitterFrames {
pub alpha: Option<Entity>,
/// What losing `owner` means ([`OwnerLoss`]).
pub on_owner_loss: OwnerLoss,
/// Whether the model's world motion reaches the particles through the emitter matrix — a FREE
/// world model like a missile ([`ParticleEmitter::world_composed`]). Defaults `false`: every
/// scene-graph-carried lane (creatures, doodads, kit effects, held items) rides.
pub world_composed: bool,
}
/// Spawn an emitter entity for one [`ModelEmitter`] at `placement`. `None` if the emitter has no
@@ -594,6 +613,7 @@ pub fn spawn_emitter(
alpha_src: frames.alpha,
alpha: 1.0,
anchor: frames.anchor,
world_composed: frames.world_composed,
anchor_pos: placement.translation,
particles: Vec::new(),
accumulator: 0.0,
+92 -17
View File
@@ -80,6 +80,39 @@ fn integrate_particle(p: &mut Particle, env: &StepEnv) -> bool {
true
}
/// **How much of the emitter's per-frame world motion a live particle keeps** — the ride-vs-trail
/// law, in one place (decision 0986; `speed` is `|Δ| / dt`, the yd/s the authored follow response
/// is keyed on). Two inputs, and reading the first one off the *flag* is what left every hunter
/// shot a compact blob where the reference draws a 20-yd bead trail (bug B153):
///
/// 1. **The baseline is the host class, not a flag** (wow-re `part-emitter-motion.md` §2b's own
/// discriminator — 0513 read it as follow-vs-no-follow): a cloud whose model the scene graph
/// carries keeps **100%** (the running kobold's candle rides, file flags `0x01`); a FREE world
/// model that composes its own motion into the emitter matrix — §2b's "a translating missile
/// whose own model IS the emitter" — keeps **0%**, its births baking world-absolute so each
/// particle hangs where it was born. Multi-Shot's FLARE emitters are as unflagged as the
/// kobold's candle (`0x0309`) and trail the arrow the length of its flight.
/// 2. **The follow-delta term** (file `0x4000`, §5-resolved decision 0513) then *overrides* the
/// baseline with the authored two-point response
/// ([`benilla_formats::ParticleEmitterDef::follow_line`]). Which is exactly why the corpus
/// authors it on missiles and nothing else: it exists to claw a fast projectile's head glow
/// back onto the tip (ArcaneShot: 0.1 @ 2.5 yd/s → 0.9 @ 16.7) while the unflagged emitters on
/// the same model stay behind. A degenerate response — equal authored speeds, which the
/// reference answers by zeroing both — is no follow term at all, so the baseline stands.
fn world_motion_kept(
def: &benilla_formats::ParticleEmitterDef,
world_composed: bool,
speed: f32,
) -> f32 {
let baseline = if world_composed { 0.0 } else { 1.0 };
if !def.follow_emitter() {
return baseline;
}
def.follow_line().map_or(baseline, |(slope, intercept)| {
(slope * speed + intercept).clamp(0.0, 1.0)
})
}
/// The velocity-inherit trigger (wow-re `part-emitter-motion.md` §1, `0x7b5230` bytes
/// 0x7b53ce–0x7b54ca): accumulate dt; once past 1/30 s (`_DAT_0081d82c`), hold
/// `oneFrameΔ · ((1/30)/accum) · scale` — zeroed while nothing is live — and reset. Between
@@ -538,6 +571,7 @@ pub(super) fn simulate_particles(
alpha_src,
alpha,
anchor,
world_composed,
anchor_pos,
particles,
accumulator,
@@ -684,23 +718,18 @@ pub(super) fn simulate_particles(
))
}
};
// FOLLOW-DELTA (file 0x4000 — wow-re `part-emitter-motion.md` §2 + §2b, §5-resolved):
// a flagged emitter's particles keep exactly `fraction = clamp(slope·|Δ|/dt +
// intercept, 0, 1)` of the emitter's per-frame WORLD motion (the authored two-point
// response, [`benilla_formats::ParticleEmitterDef::follow_line`]) — the reference's
// baseline for this content class is world-frozen (its emitter motion folds into the
// emitter matrix, so births bake world-absolute) and the `+fraction·Δ` integrator add
// recovers the ride. Over OUR anchor-riding storage the same observable is
// `(fraction − 1)·Δ`: zero at saturation (rigid ride, the fast missile), the full
// `−Δ` at fraction 0 (a world-frozen trail). Applied to every live particle after its
// first integrate (the fresh-bit skip).
let follow = if def.follow_emitter() && emitter_delta != Vec3::ZERO {
def.follow_line().map_or(Vec3::ZERO, |(slope, intercept)| {
let fraction = (slope * emitter_delta.length() / dt + intercept).clamp(0.0, 1.0);
to_stored((fraction - 1.0) * emitter_delta, attach_inv, placement)
})
} else {
// The per-frame world-motion carry ([`world_motion_kept`]), folded into the stored frame:
// keeping `fraction` of Δ over our anchor-RIDING storage is a `(fraction − 1)·Δ` move on
// every live particle after its first integrate (the fresh-bit skip).
let follow = if emitter_delta == Vec3::ZERO {
Vec3::ZERO
} else {
let fraction = world_motion_kept(def, *world_composed, emitter_delta.length() / dt);
if fraction >= 1.0 {
Vec3::ZERO // the rigid ride — the overwhelming majority, kept free
} else {
to_stored((fraction - 1.0) * emitter_delta, attach_inv, placement)
}
};
// VELOCITY INHERIT (file 0x40): the ~30 Hz trigger holds the inherit velocity births
// read; the live gate (rt+0x64) zeroes it while nothing is live.
@@ -1121,7 +1150,8 @@ pub(super) fn simulate_particles(
#[cfg(test)]
mod tests {
use super::{
inherit_trigger, integrate_particle, is_above, ChildEmitter, Particle, StepEnv, Vec3,
inherit_trigger, integrate_particle, is_above, world_motion_kept, ChildEmitter, Particle,
StepEnv, Vec3,
};
use bevy::prelude::{Quat, Transform};
@@ -1196,6 +1226,51 @@ mod tests {
assert!(!integrate_particle(&mut out, &kill));
}
/// [`world_motion_kept`] — the ride-vs-trail law (0986). The host class sets the baseline;
/// the follow flag overrides it with the authored response; a degenerate response falls back
/// to the baseline.
#[test]
fn world_motion_kept_reads_the_host_class_then_the_follow_response() {
let plain = crate::particles::tests::plain_def(); // no flags
assert_eq!(
world_motion_kept(&plain, false, 30.0),
1.0,
"scene-graph-carried, unflagged: the kobold's candle rides at any speed"
);
assert_eq!(
world_motion_kept(&plain, true, 30.0),
0.0,
"a free world model, unflagged: Multi-Shot's flares hang where they were born"
);
// ArcaneShot's authored pair: 0.1 @ 2.5 yd/s, 0.9 @ 16.667 — the head glow catching up.
let following = benilla_formats::ParticleEmitterDef {
flags: 0x4000,
follow_speed1: 2.5,
follow_scale1: 0.1,
follow_speed2: 16.667,
follow_scale2: 0.9,
..crate::particles::tests::plain_def()
};
assert!(
(world_motion_kept(&following, true, 2.5) - 0.1).abs() < 1e-3,
"the flag overrides the world-frozen baseline with the authored response"
);
assert_eq!(
world_motion_kept(&following, true, 40.0),
1.0,
"clamped at a rigid ride on a fast missile — it never leads"
);
// Equal authored speeds: the reference zeroes both, so nothing overrides the baseline.
let degenerate = benilla_formats::ParticleEmitterDef {
flags: 0x4000,
follow_speed1: 4.0,
follow_speed2: 4.0,
..crate::particles::tests::plain_def()
};
assert_eq!(world_motion_kept(&degenerate, true, 30.0), 0.0);
assert_eq!(world_motion_kept(&degenerate, false, 30.0), 1.0);
}
/// FOLLOW-DELTA (`0x7b2680` @0x7b2744, rt 0x40000): the shared per-frame vector moves every
/// live particle — except each particle's FIRST integrate, which consumes its fresh bit
/// instead (the reference's particle+0xd first-frame skip).
+3
View File
@@ -177,6 +177,9 @@ pub(super) fn spawn_booth_effects(
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
crate::particles::EmitClock::Pinned, // an item's effects loop forever
) else {
@@ -700,6 +700,9 @@ pub(super) fn sync_glue_scene(
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// A glue scene loops its one authored clip forever — the doodad law.
crate::particles::EmitClock::Pinned,
+4 -1
View File
@@ -205,7 +205,10 @@ fn play_item_gesture_sounds(
&config,
);
}
CursorPayload::Spell(_) | CursorPayload::Action(_) => {
// Everything that is not a live item shares the generic grab/drop kit — the reference's
// own `0x494f60`/`0x494f80` grab path for a macro (mode 8) reaches the same
// `INTERFACESOUND_CURSOR*` pair the spell and bar-action modes do.
CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_) => {
let kit_id = match gesture {
CursorGesture::Gain => INTERFACESOUND_CURSORGRABOBJECT,
CursorGesture::Loss => INTERFACESOUND_CURSORDROPOBJECT,
+19 -3
View File
@@ -5,7 +5,8 @@
//! (6603) sends `CMSG_ATTACKSWING` at the selection, or acquires the nearest enemy when there is
//! none; an ITEM action names an *entry*, not a position, so it must first find a copy and then
//! decide equip-vs-use — [`item_action_route`], the byte-verified two-stage law of decision 0666.
//! Macro actions are a stated gap (no macro window yet).
//! A MACRO action runs its body's lines through the chat-input door (`crate::ui_macro::run`,
//! decision 0983) — the `0x4f1460` fork of the reference's own `UseAction`.
//! - **Set** ([`drain_action_sets`]): a queued `PickupAction`/`PlaceAction` mutation becomes one
//! `CMSG_SET_ACTION_BUTTON` per entry (0218 §4: the bar is client-authoritative, there is no
//! answer packet to lock against, and a drag-swap is two independent sends — never atomic).
@@ -15,7 +16,9 @@
use bevy::prelude::*;
use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use benilla_protocol::messages::{
ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL,
};
use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands};
@@ -252,9 +255,22 @@ pub(super) fn drain_action_uses(
);
}
}
// The MACRO arm (`0x4e5ee0`'s `and ecx,0xbfffffff; call 0x4f1460` fork, wow-re
// `action-item-slot.md` §8): run the macro's body. Every line goes onto the chat-input
// queue — the door a typed line comes through — so `/cast`, `/target`, `/script`, the
// chat types and the 225 emotes all work in a macro by construction
// (`crate::ui_macro::run`'s module doc).
Some(b) if b.kind == ACTION_KIND_MACRO => {
if !crate::ui_macro::run_macro(&mut script, b.action) {
debug!(
"ui_action: macro action {action} (macro {}) is empty",
b.action
);
}
}
Some(b) => {
debug!(
"ui_action: action {action} kind {:#04x} not castable yet (macro)",
"ui_action: action {action} kind {:#04x} has no use path",
b.kind
);
}
+25 -4
View File
@@ -26,7 +26,7 @@ use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
@@ -54,6 +54,10 @@ pub(super) struct FeedMemory {
/// The [`Items::template_epoch`] the last identity resolve ran at — the feed's half of the
/// landed-template redisplay (decision 0660). An advance re-resolves, exactly like a bar edit.
template_epoch: u64,
/// The macro-table generation the last identity resolve ran at (decision 0983) — the THIRD
/// input, exactly like `template_epoch` above: editing a macro changes its bar icon while
/// touching neither the action table nor any item template.
macro_generation: u64,
}
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
@@ -229,9 +233,16 @@ pub(super) fn feed_actions(
// ask and the one and only feed landed 0.5 ms apart, in that order, and nothing re-fed).
// The epoch is the second input, so a landed answer redisplays like the ref's DBCACHECALLBACK.
let template_epoch = items.template_epoch();
if actions.dirty || template_epoch != memory.template_epoch {
let macro_generation = script.macros_generation();
if actions.dirty
|| template_epoch != memory.template_epoch
|| macro_generation != memory.macro_generation
{
actions.dirty = false;
memory.template_epoch = template_epoch;
memory.macro_generation = macro_generation;
// Cloned once per re-resolve, never per frame: the gate above is a `u64` compare.
let macros = script.macros();
// Resolve every occupied wire slot to its display, diff against what the VM holds, push +
// fire ACTIONBAR_SLOT_CHANGED (arg1 = the Lua action id) per transition. Item icons/counts
@@ -275,8 +286,18 @@ pub(super) fn feed_actions(
.unwrap_or(0);
(Some(texture), count)
}
// Macro actions: no macro catalog yet (no macro window ships) — the engine-side
// fallback icon shows, matching the pre-slice-4 gap.
// A MACRO slot serves **the macro's own icon, never its bound spell's** — the one
// asymmetry in the icon resolver, byte-verified: `0x4e6a50`'s macro arm
// (`0x4e6bf9`) validates the slot and calls `0x4f0fd0(idx, buf, 0x104)`, the macro
// record's own icon-path builder, without ever touching `[rec+0x564]` (wow-re
// `action-spell-icon-apis.md` §3.7). Its dynamic state DOES go through the bound
// spell — that split is the whole design (`state`'s macro arm, decision 0983).
ACTION_KIND_MACRO => (
macros
.get(button.action as usize)
.and_then(|m| m.texture.clone()),
0,
),
_ => (None, 0),
};
fresh.insert(
@@ -167,3 +167,82 @@ fn an_unknown_entry_answers_once_and_settles() {
"the negative is cached — the feed must not re-ask on the next resolve"
);
}
/// A MACRO slot serves **the macro's own icon**, and follows an EDIT of that macro without any
/// bar-table change at all (decision 0983).
///
/// Two things are pinned. The icon rule is byte-verified: `GetActionTexture`'s macro arm
/// (`0x4e6bf9`) builds the macro record's own icon path and never touches the bound spell
/// (`action-spell-icon-apis.md` §3.7). And the *trigger* is the macro-table generation — the third
/// input beside `dirty` and the item-template epoch — because renaming or re-iconing a macro moves
/// neither of those, and gating on them alone leaves a stale icon on the bar until some unrelated
/// edit happens to re-dirty the feed (exactly decision 0660's bug, one seam over).
#[test]
fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
use benilla_protocol::messages::ACTION_KIND_MACRO;
use benilla_ui::script::{MacroState, MacroView};
const SLOT: u8 = 0;
const ACTION: u32 = 1;
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut actions = PlayerActions::default();
actions.buttons.insert(
SLOT,
ActionButton {
slot: SLOT,
action: 1, // macro index 1
kind: ACTION_KIND_MACRO,
},
);
actions.dirty = true;
app.insert_resource(actions)
.init_resource::<Items>()
.init_resource::<CastErrors>()
.init_resource::<MountErrors>()
.init_resource::<UiErrorKeys>()
.insert_resource(NetCommands(tx));
let mut script = UiScript::new().unwrap();
script.set_macros(MacroState {
account: vec![MacroView {
name: "Ambush".into(),
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
// The bound spell is deliberately NOT what the icon shows.
body: "/cast Ambush".into(),
local_only: false,
}],
character: Vec::new(),
});
app.insert_non_send_resource(script);
app.add_systems(Update, feed_actions);
app.update();
let icon = |app: &mut App| {
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<Option<String>>(&format!("return GetActionTexture({ACTION})"))
.unwrap()
};
assert_eq!(
icon(&mut app).as_deref(),
Some("Interface\\Icons\\Ability_Ambush"),
"the MACRO's own icon"
);
// Re-icon the macro. Nothing touches `PlayerActions` — only the macro table moves.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(r#"EditMacro(1, nil, "Interface\\Icons\\Spell_Fire_FlameBolt")"#)
.unwrap();
assert!(
!app.world().resource::<PlayerActions>().dirty,
"the precondition: the bar table is UNtouched by a macro edit"
);
app.update();
assert_eq!(
icon(&mut app).as_deref(),
Some("Interface\\Icons\\Spell_Fire_FlameBolt"),
"the generation gate re-resolved the slot"
);
}
+70 -4
View File
@@ -29,7 +29,7 @@ use std::time::Instant;
use bevy::prelude::*;
use benilla_formats::{SpellDisplay, SpellRange};
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use benilla_ui::script::{ActionState, UiScript};
use crate::cooldowns::Cooldowns;
@@ -193,6 +193,30 @@ fn resolve_range(
Some((min, row.max + pad))
}
/// Resolve a slot's `(kind, id)` **through** a macro before any state is computed — the
/// reference's own shape, and the reason a macro button on the bar wears its spell's cooldown
/// swirl, usability tint, range colour and checked ring while showing its own icon.
///
/// Every `Is*Action`/`GetActionCooldown` binding routes through the one slot→spell resolver
/// `0x4e5a50`, whose MACRO arm resolves the macro record and returns `[rec+0x564]` as the slot's
/// spell id (wow-re `action-spell-icon-apis.md` §2, VERIFIED). So from here down, a macro that
/// casts Fireball simply *is* the Fireball slot. `GetActionTexture` is the deliberate exception —
/// its macro arm keeps the macro's own icon (`super::feed`).
///
/// `None` = nothing to report (a macro bound to no spell; the reference's `[rec+0x564] == 0`).
/// Only the SPELL indirection is modelled: 1.12 has no `/use <item>` slash command, so no 1.12
/// macro body can name an item and the resolver's item leg is unreachable from one.
fn resolve_through_macro(
kind: u8,
action: u32,
bound: &crate::ui_macro::MacroBoundSpells,
) -> Option<(u8, u32)> {
match kind {
ACTION_KIND_MACRO => bound.0.get(&action).map(|&s| (ACTION_KIND_SPELL, s)),
other => Some((other, action)),
}
}
/// Compute + diff-push every occupied slot's dynamic state, and fire the reference event edges.
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // a Bevy system's full input set
pub(super) fn feed_action_state(
@@ -204,12 +228,14 @@ pub(super) fn feed_action_state(
auto_repeat: Res<AutoRepeatActive>,
// One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking — the in-flight
// guard, the queued on-next-swing strike, the running channel, and the awaiting-click
// ground targeting.
// ground targeting — plus the macro→spell binding the MACRO arm resolves through
// (decision 0983), which rides here for the same ceiling reason.
cast_state: (
Res<crate::ui_cast::PendingCast>,
Res<crate::ui_cast::QueuedMeleeSpell>,
Res<crate::ui_cast::ActiveChannel>,
Res<super::SpellTargeting>,
Res<crate::ui_macro::MacroBoundSpells>,
),
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
selection: Res<Selection>,
@@ -241,7 +267,7 @@ pub(super) fn feed_action_state(
memory.last_cd_trace = Some(now);
}
let (pending, queued_melee, channel, targeting) = &cast_state;
let (pending, queued_melee, channel, targeting, bound) = &cast_state;
let me = self_q.iter().next();
let engaged = me.is_some_and(|(_, _, e, _)| e);
let form_byte = me
@@ -266,6 +292,16 @@ pub(super) fn feed_action_state(
for (&slot, button) in &actions.buttons {
let action = u32::from(slot) + 1;
let mut st = ActionState::default();
let Some((kind, id)) = resolve_through_macro(button.kind, button.action, bound) else {
// A macro that casts nothing reports nothing — the reference's `[rec+0x564] == 0`.
fresh.insert(action, st);
continue;
};
let button = &benilla_protocol::messages::ActionButton {
slot,
action: id,
kind,
};
match button.kind {
ACTION_KIND_SPELL => {
let d = spells.as_ref().and_then(|s| s.catalog.get(button.action));
@@ -367,7 +403,7 @@ pub(super) fn feed_action_state(
st.cooldown = info.ui_triple(anchor, ui_now);
}
}
_ => {} // macros: everything cold until a macro window exists
_ => {}
}
// No between-generation carry: the triple holds the ABSOLUTE start, so one running
// cooldown re-derives the same value every frame (no diff churn) and a re-arm derives a
@@ -631,6 +667,36 @@ mod tests {
);
}
/// A MACRO slot resolves through its bound spell for EVERY dynamic read (decision 0983) —
/// the `0x4e5a50` law — while an unbound macro reports nothing at all.
#[test]
fn a_macro_slot_resolves_through_its_bound_spell() {
use benilla_protocol::messages::ACTION_KIND_MACRO;
let mut bound = crate::ui_macro::MacroBoundSpells::default();
bound.0.insert(3, 133); // macro 3 casts Fireball
assert_eq!(
resolve_through_macro(ACTION_KIND_MACRO, 3, &bound),
Some((ACTION_KIND_SPELL, 133)),
"from here down the macro IS the Fireball slot"
);
assert_eq!(
resolve_through_macro(ACTION_KIND_MACRO, 4, &bound),
None,
"a macro that casts nothing has no cooldown, no range, no usability"
);
// Spell and item slots pass through untouched.
assert_eq!(
resolve_through_macro(ACTION_KIND_SPELL, 133, &bound),
Some((ACTION_KIND_SPELL, 133))
);
assert_eq!(
resolve_through_macro(ACTION_KIND_ITEM, 117, &bound),
Some((ACTION_KIND_ITEM, 117))
);
}
/// The mounted refusal (`0x609c6c`): a live mount blocks unless Attributes carries the
/// bit-24 exemption (`0x609c6f`); unmounted always passes; a missing record can claim no
/// exemption, so the gate holds.
+1 -1
View File
@@ -12,7 +12,7 @@ use bevy::prelude::*;
use crate::ui_script::UiInput;
mod commands;
pub(crate) mod commands;
mod edit;
mod event;
mod feed;
+20 -6
View File
@@ -42,7 +42,7 @@ use bevy::prelude::*;
/// `SLASH_<INDEX><n>` alias-string prefix ([`SlashIndex::key`]). Adding a command is adding a
/// variant + its handler arm; its aliases arrive with the shipped strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SlashIndex {
pub(crate) enum SlashIndex {
Reply,
Join,
Leave,
@@ -74,6 +74,14 @@ pub(super) enum SlashIndex {
Target,
Assist,
Follow,
/// `/cast <name>` — the reference's `SlashCmdList["CAST"]` is one line, `CastSpellByName(msg)`,
/// and that binding is the engine seam benilla implements over the spell book (decision 0983).
/// The command the whole macro system exists to run.
Cast,
/// `/macro` `/m` — opens the macro window (the ref's `ShowMacroFrame()`).
MacroUi,
/// `/macrohelp` — the reference's own five-line help text.
MacroHelp,
}
impl SlashIndex {
@@ -111,13 +119,16 @@ impl SlashIndex {
Self::Target => "TARGET",
Self::Assist => "ASSIST",
Self::Follow => "FOLLOW",
Self::Cast => "CAST",
Self::MacroUi => "MACRO",
Self::MacroHelp => "MACROHELP",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 31] = [
const ALL: [Self; 34] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -149,6 +160,9 @@ impl SlashIndex {
Self::Target,
Self::Assist,
Self::Follow,
Self::Cast,
Self::MacroUi,
Self::MacroHelp,
];
}
@@ -156,7 +170,7 @@ impl SlashIndex {
/// (decisions 0099 `/castvis`, 0288 `/chattest`, 0434 `/partytest`, 0600 `/shot`, 0634 `/liquid`,
/// 0637 `/reaction`). Their aliases are literals because there is nothing shipped to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DevCmd {
pub(crate) enum DevCmd {
CastVis,
ChatTest,
PartyTest,
@@ -189,7 +203,7 @@ impl DevCmd {
/// What a typed `/command` resolved to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Command {
pub(crate) enum Command {
/// A reference `SlashCmdList` command benilla implements.
Slash(SlashIndex),
/// An emote alias — carrying the `EmotesText` id its token resolved to (`/lol` → LAUGH → 45).
@@ -210,14 +224,14 @@ pub(crate) struct SlashCommands {
impl SlashCommands {
/// Resolve a typed command (no leading `/`, any case).
pub(super) fn lookup(&self, cmd: &str) -> Option<Command> {
pub(crate) fn lookup(&self, cmd: &str) -> Option<Command> {
self.by_alias.get(&cmd.to_ascii_lowercase()).copied()
}
/// Build from the reference's alias strings. `get` reads a global string (the UI VM's globals in
/// production, a stub map in tests); `text_id` resolves an `EmotesText` NAME to its id (the
/// emote catalog). Pure over those two, so the whole table is testable without a VM.
pub(super) fn build(
pub(crate) fn build(
get: impl Fn(&str) -> Option<String>,
text_id: impl Fn(&str) -> Option<u32>,
) -> Self {
+19
View File
@@ -562,6 +562,8 @@ pub(super) fn drain_chat_input(
"Duel: /duel [name — bare uses your target], /forfeit (/concede /yield)",
"Social: /who [filter], /friends, /ignore, /trade, /inspect",
"Emotes: /sit /stand /sleep /kneel and every /wave-style emote",
"Spells: /cast <name> [(Rank N)]",
"Macros: /macro (/m) opens the window, /macrohelp explains them",
"Misc: /afk, /dnd, /random [min] [max], /played, /logout, /quit",
"Instruments: /shot, /liquid, /reaction, /castvis, /chattest, /partytest",
] {
@@ -571,6 +573,23 @@ pub(super) fn drain_chat_input(
));
}
}
// `ChatFrame_DisplayMacroHelpText` (ChatFrame.lua): the five shipped lines, read off
// the VM's own GlobalStrings so they are the install's text, never a transcription
// (decision 0983; the 0881 posture — the strings are data, the handler is ours).
ParsedChat::MacroHelp => {
for i in 1..=5 {
let key = format!("MACRO_HELP_TEXT_LINE{i}");
let Ok(line) = script.lua().globals().get::<String>(key.as_str()) else {
continue;
};
if !line.is_empty() {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line,
));
}
}
}
// `DoEmote` (`0x5ef560`) end to end — wow-re `object-layer/scratch/emote-posture-
// gate.md` §1. The gates in the client's own order, then the two things it DOES: set a
// posture (the `/sit` family) and send the packet.
+24
View File
@@ -172,6 +172,10 @@ pub(in crate::ui_chat) enum ParsedChat {
/// only living assistable ones (the ref's filter mode 2); bare follows the current selection,
/// creature or player. Client-side movement — nothing goes on the wire.
Follow { name: Option<String> },
/// `/macrohelp` — the reference's `ChatFrame_DisplayMacroHelpText`: `MACRO_HELP_TEXT_LINE1..5`
/// straight out of the shipped `GlobalStrings.lua` (decision 0983). Resolved in the drain,
/// which holds the VM, so the text can never go stale against the install.
MacroHelp,
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
@@ -358,6 +362,26 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
},
None => ParsedChat::Unknown,
},
// `/cast <name>` = the ref's `SlashCmdList["CAST"]` verbatim: `if msg ~= "" then
// CastSpellByName(msg) end` (ChatFrame.lua:1120). The binding does the book lookup
// (`benilla_ui::script::spellbook::resolve_spell_by_name`) and queues onto the ONE cast
// path, so a typed `/cast` and a macro's `/cast` line are the same code — decision 0983.
S::Cast => {
if args.is_empty() {
ParsedChat::Unknown
} else {
ParsedChat::Lua {
body: format!("CastSpellByName(\"{}\")", escape_lua_string(args)),
}
}
}
// `/macro` `/m` = the ref's own body: `ShowMacroFrame()` (the `RunMacro(msg)` branch beside
// it is commented out in the shipped 1.12 ChatFrame.lua — there is no `RunMacro` binding in
// the client at all, confirmed against the registered-binding scan).
S::MacroUi => ParsedChat::Lua {
body: "ShowMacroFrame()".into(),
},
S::MacroHelp => ParsedChat::MacroHelp,
// `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped.
S::Script => {
if args.is_empty() {
+35 -4
View File
@@ -650,12 +650,43 @@ fn real_alias_table_resolves_the_shipped_commands() {
}
);
assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None });
// The macro family (decision 0983): `/cast` (with its `/spell` alias — SLASH_CAST1-4 spell two
// distinct strings across four slots) runs the ref's own one-line body `CastSpellByName(msg)`;
// `/macro`/`/m` open the window; `/macrohelp` prints the shipped five lines.
assert_eq!(
parse_line("/cast Fireball(Rank 1)"),
ParsedChat::Lua {
body: "CastSpellByName(\"Fireball(Rank 1)\")".into()
}
);
assert_eq!(
parse_line("/spell Frostbolt"),
ParsedChat::Lua {
body: "CastSpellByName(\"Frostbolt\")".into()
}
);
assert_eq!(
parse_line("/cast"),
ParsedChat::Unknown,
"a bare /cast is the ref's own no-op (`if msg ~= \"\"`)"
);
for line in ["/macro", "/m"] {
assert_eq!(
parse_line(line),
ParsedChat::Lua {
body: "ShowMacroFrame()".into()
},
"{line}"
);
}
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **62 distinct
// aliases** across the 31 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`).
assert_eq!(table.counts(), (62, 225), "(slash, emote) aliases");
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **67 distinct
// aliases** across the 34 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`;
// 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`).
assert_eq!(table.counts(), (67, 225), "(slash, emote) aliases");
}
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
+239
View File
@@ -0,0 +1,239 @@
//! The **macros** feed (decision 0983) — the app half of `benilla_ui::script::macros`: the icon
//! chooser's catalog, persistence under `benilla/macros/`, and `UPDATE_MACROS`.
//!
//! Unlike every other window feed in this tree there is **no wire traffic here at all**. 1.12
//! macros are pure client state — no opcode carries them (vmangos has none), and the reference
//! client persists them to `WTF/…/macros-cache.txt` itself. So the engine owns the live table
//! (`benilla_ui::script::macros`' module doc says why) and this module owns its two ends:
//!
//! - **In** — the icon list (`SpellIcon.dbc`, filtered by the client's own `Spell_`/`Ability_`
//! prefixes: [`benilla_formats::load_macro_icons`]) at startup, and the saved files at world
//! entry.
//! - **Out** — a save whenever a script mutated the table ([`save_dirty_macros`]), plus the
//! reference's own `UPDATE_MACROS` event on the same edge.
//!
//! The **runner** and the action-bar's **bound spell** live in [`run`]; the file format is
//! [`store`] (the reference's own, so a vanilla `macros-cache.txt` drops straight in).
use bevy::prelude::*;
use benilla_ui::script::{MacroState, UiScript};
use crate::assets::{LockRecover, WorldAssets};
use crate::char_select::ClientState;
pub(crate) mod run;
mod store;
#[cfg(test)]
mod tests;
/// Which files this session's macros live in — resolved once the character is known, since the
/// per-character tab is keyed by realm + name exactly as the reference's own folder tree is.
/// `None` on either path means "session-only": a hermetic capture, or no resolvable install
/// (`crate::local_state`'s law). Macros still work in memory; nothing is written.
#[derive(Resource, Default)]
pub(crate) struct MacroFiles {
account: Option<std::path::PathBuf>,
character: Option<std::path::PathBuf>,
/// The `(realm, character)` the [`Self::character`] path was built for — the reload trigger
/// when a `/logout` brings a different character back into the world.
identity: Option<(String, String)>,
}
/// Macro index → the spell it casts — benilla's `[rec+0x564]` (wow-re
/// `action-spell-icon-apis.md` §2), the field an action-bar MACRO slot's whole dynamic state reads
/// through. Stored, not derived at read time, for the reference's own reason: it is a **field on
/// the macro record**, recomputed when the macro (or the book) changes, so the three per-frame
/// action-bar systems pay a hash lookup instead of a body re-parse each.
#[derive(Resource, Default)]
pub(crate) struct MacroBoundSpells(pub(crate) std::collections::HashMap<u32, u32>);
pub(crate) struct UiMacroPlugin;
impl Plugin for UiMacroPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MacroFiles>()
.init_resource::<MacroBoundSpells>()
// `PostStartup`, not `Startup.after(AssetSet::Open)` — the same reason
// `crate::ui_chat::commands::build_slash_commands` sits there: this needs BOTH the
// patch chain and the VM, and the VM is inserted by another `Startup` system whose
// `insert_non_send_resource` only lands at the schedule boundary. Ordered after
// `AssetSet::Open` alone, this ran before the VM existed and silently pushed no icon
// list at all (caught on a clean capture run: the boot line never appeared).
.add_systems(PostStartup, load_icon_catalog)
.add_systems(
Update,
(
// Before the action feeds read it (they run in `UnitFeed`), so a macro edited
// this frame reports its new spell's cooldown the same frame.
rebind_macro_spells.before(crate::ui_unit::UnitFeed),
// Load runs in-world only: the per-character file needs the character, and the
// roster only names it once a login is live. It self-gates on the identity, so
// a re-entry with a different character reloads and a re-entry with the same
// one is a no-op.
load_macros.run_if(in_state(ClientState::InWorld)),
// The save edge is checked every frame, in or out of world: the macro window is
// `whileDead = 1` and reachable from the game menu, and a `/logout` must not
// strand an unsaved edit.
save_dirty_macros.after(load_macros),
),
);
}
}
/// Build the icon chooser's list once at startup — `SpellIcon.dbc` filtered by the client's own
/// two prefixes ([`benilla_formats::load_macro_icons`], where the byte citations live). A failed
/// load leaves the list empty: the popup then shows no icons and `MacroPopupOkayButton_Update`
/// keeps OKAY disabled, which is a visible, diagnosable failure rather than a silent one.
fn load_icon_catalog(script: Option<NonSendMut<UiScript>>, assets: Option<Res<WorldAssets>>) {
let (Some(mut script), Some(assets)) = (script, assets) else {
return;
};
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_macro_icons(&mut chain)
};
match loaded {
Ok(icons) => {
info!("ui_macro: {} macro icons in the chooser", icons.len());
script.set_macro_icons(icons);
}
Err(e) => error!("ui_macro: SpellIcon.dbc failed — the icon chooser is empty: {e:#}"),
}
}
/// Who we are, for the per-character file: `(realm, character)` off the roster's own login pick.
/// `None` until the roster and the pick agree — the load simply waits a frame.
fn identity(roster: &crate::char_select::Roster) -> Option<(String, String)> {
let guid = roster.pending_pick?;
let name = roster.chars.iter().find(|c| c.guid == guid)?.name.clone();
let realm = roster
.realm
.as_ref()
.map(|r| r.name.clone())
.unwrap_or_else(|| "Realm".into());
Some((realm, name))
}
/// Seed the engine's macro table from disk, once per character per world entry.
fn load_macros(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut files: ResMut<MacroFiles>,
) {
let Some(mut script) = script else { return };
let Some(id) = identity(&roster) else { return };
if files.identity.as_ref() == Some(&id) {
return; // already loaded for this character
}
let (realm, character) = (&id.0, &id.1);
files.account = crate::local_state::macros_account_path();
files.character = crate::local_state::macros_character_path(realm, character);
files.identity = Some(id.clone());
let read = |path: &Option<std::path::PathBuf>| -> Vec<benilla_ui::script::MacroView> {
let Some(path) = path else { return Vec::new() };
match std::fs::read_to_string(path) {
Ok(text) => store::parse(&text),
// Absent is the normal first-run case, not a failure; anything else is worth a line.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
warn!("ui_macro: reading {}: {e}", path.display());
Vec::new()
}
}
};
let state = MacroState {
account: read(&files.account),
character: read(&files.character),
};
info!(
"ui_macro: {} account + {} character macros for {character} on {realm}",
state.account.len(),
state.character.len()
);
script.set_macros(state);
// The reference's own event for "the macro table changed" (`UPDATE_MACROS`, byte-verified
// string at `0x452460`) — the frame redraws off it exactly as it does after an edit.
script.fire_event("UPDATE_MACROS", vec![]);
}
/// Persist on the engine's dirty edge, and fire `UPDATE_MACROS`.
///
/// Deliberately **not** coalesced behind a timer the way `config.toml`'s save is (decision 0954
/// §3): a macro edit is a discrete, deliberate act (an OKAY click, a tab switch, a window close),
/// not a slider drag, and the file is a few hundred bytes. The dirty flag is already the
/// coalescer — a whole `MacroFrame_SaveMacro` + `MacroFrame_Update` round raises it once.
fn save_dirty_macros(script: Option<NonSendMut<UiScript>>, files: Res<MacroFiles>) {
let Some(mut script) = script else { return };
if !script.take_macros_dirty() {
return;
}
let state = script.macros();
// Fire first: the event is the UI's redraw trigger and must not depend on the write landing
// (a read-only `benilla/` folder still gets a live macro list for the session).
script.fire_event("UPDATE_MACROS", vec![]);
for (path, macros) in [
(&files.account, &state.account),
(&files.character, &state.character),
] {
let Some(path) = path else { continue };
if let Err(e) = crate::local_state::write_atomic(path, &store::write(macros)) {
warn!("ui_macro: saving {}: {e}", path.display());
}
}
}
/// Recompute every macro's bound spell when the macro table or the spell book moves ([`run`]'s
/// module doc for what "bound" means and where it is byte-verified). Change-gated on the engine's
/// macro generation plus Bevy's own change detection over the action store (the book derives from
/// its known-spell set), so a steady frame does nothing at all.
fn rebind_macro_spells(
script: Option<NonSendMut<UiScript>>,
actions: Res<crate::ui_action::PlayerActions>,
table: Option<Res<crate::ui_chat::commands::SlashCommands>>,
mut bound: ResMut<MacroBoundSpells>,
mut last_generation: Local<Option<u64>>,
) {
let (Some(script), Some(table)) = (script, table) else {
return;
};
let generation = script.macros_generation();
if *last_generation == Some(generation) && !actions.is_changed() {
return;
}
*last_generation = Some(generation);
let (macros, book) = (script.macros(), script.spellbook());
let mut fresh = std::collections::HashMap::new();
for index in 1..=(benilla_ui::script::MAX_MACROS as u32 * 2) {
let Some(m) = macros.get(index as usize) else {
continue;
};
if let Some(spell) = run::bound_spell(&table, &m.body, &book) {
fresh.insert(index, spell);
}
}
if fresh != bound.0 {
debug!("ui_macro: {} macro(s) bound to a spell", fresh.len());
bound.0 = fresh;
}
}
/// Run a macro by its 1-based index — the action bar's MACRO arm (`crate::ui_action::drain`) and
/// nothing else today. Each body line is pushed onto the chat-input queue in order, which is where
/// a typed line arrives; [`run`]'s module doc is why. Returns whether anything ran.
pub(crate) fn run_macro(script: &mut UiScript, index: u32) -> bool {
let Some(body) = script.macros().get(index as usize).map(|m| m.body.clone()) else {
return false;
};
let lines: Vec<String> = run::macro_lines(&body).map(str::to_string).collect();
if lines.is_empty() {
return false;
}
debug!("ui_macro: running macro {index} ({} line(s))", lines.len());
for line in lines {
script.push_chat_input(line);
}
true
}
+204
View File
@@ -0,0 +1,204 @@
//! **Running** a macro, and the one derivation the action bar needs from a macro body: its
//! **bound spell** (decision 0983).
//!
//! ## Running: a macro line is a typed chat line
//!
//! The reference reaches Lua's `SlashCmdList` for `/target`, `/script`, the chat types and the 225
//! emotes — every one of those handlers *is* FrameXML, so a macro that drives them must arrive
//! where a typed line arrives. benilla has exactly one such door already, and it is not a chat
//! window: [`UiScript::push_chat_input`], the seam `WOW_PROBE_CHAT` drives, whose drain
//! (`crate::ui_chat::input`) is the client's whole slash grammar in one place. So a macro run is
//! **its lines pushed onto that queue, in order** — every command a player can type, a macro can
//! run, by construction, and a command added later works in macros the day it lands.
//!
//! That is the *mechanism* implemented once rather than the engine's plumbing aped: the reference
//! does this inside `UIMacros.cpp` (`0x4f1460`'s callees), and how those callees hand a line to the
//! Lua VM is **not recorded in wow-re** — see decision 0983's open question. What is settled is the
//! observable behaviour this reproduces: a `/`-line runs its command, a plain line is sent as chat
//! (the director's own 1.12 `macros-cache.txt` drives `.cheat fly on` that way), and every line in
//! the body runs.
//!
//! ## The bound spell (`[rec+0x564]`, VERIFIED consumer)
//!
//! A macro action-bar slot shows **the macro's own icon** but reports the **cooldown, usability,
//! range and checked state of the spell it casts** — byte-verified: `0x4e5a50`'s macro arm
//! resolves the macro record through `0x4f0f40` and returns `[rec+0x564]` as the slot's spell id
//! (wow-re `action-spell-icon-apis.md` §2), and every `Is*Action`/`GetActionCooldown` binding reads
//! through that resolver. [`bound_spell`] is how that field is filled: the body's first `/cast`
//! line, or a `CastSpellByName("…")` call in a `/script` line.
//!
//! Both forms come from the reference's own `UIMacros.cpp` string block, which holds exactly two
//! things it could match a body against: **`SLASH_CAST%d`** (`0x44cac4` — the global-string prefix
//! whose aliases are `/cast`) and the literal **`CastSpellByName(`** (`0x44cab0`, no format
//! specifier, so it is a *compare*, not a build). Nothing else in that TU parses a body. The
//! derivation is therefore INFERRED from a verified string set plus a verified consumer — named as
//! such in decision 0983.
use benilla_ui::script::{resolve_spell_by_name, SpellBookState};
use crate::ui_chat::commands::{Command, SlashCommands, SlashIndex};
/// The literal the reference matches a `/script`-style body line against (`0x44cab0`).
const CAST_BY_NAME_CALL: &str = "CastSpellByName(";
/// A macro body's runnable lines: split on newlines, trimmed, blanks dropped. Carriage returns go
/// too — a `macros-cache.txt` hand-copied off a Windows install is a real input.
pub(crate) fn macro_lines(body: &str) -> impl Iterator<Item = &str> {
body.lines().map(str::trim).filter(|l| !l.is_empty())
}
/// The spell name a macro body casts, if any — [`bound_spell`]'s parse half, split out because it
/// is the whole INFERRED part and deserves its own test.
///
/// Walks the body in order and returns the FIRST match of either form:
/// - a line whose command resolves to [`SlashIndex::Cast`] through the boot-built alias table
/// (never a hardcoded `"/cast"` — decision 0881's law), argument taken whole; or
/// - a line containing `CastSpellByName(` with a quoted first argument.
pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<String> {
for line in macro_lines(body) {
if let Some(rest) = line.strip_prefix('/') {
let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
if table.lookup(cmd) == Some(Command::Slash(SlashIndex::Cast)) {
let args = args.trim();
if !args.is_empty() {
return Some(args.to_string());
}
continue;
}
}
if let Some(name) = quoted_call_argument(line) {
return Some(name);
}
}
None
}
/// `… CastSpellByName("Fireball" …) …` → `Fireball`. Only a double-quoted first argument is read:
/// a computed one (`CastSpellByName(spell)`) has no name to bind at parse time, and the reference
/// — matching a bare literal with no format specifier — cannot read one either.
fn quoted_call_argument(line: &str) -> Option<String> {
let after = line.find(CAST_BY_NAME_CALL)? + CAST_BY_NAME_CALL.len();
let rest = line.get(after..)?.trim_start();
let rest = rest.strip_prefix('"')?;
let close = rest.find('"')?;
let name = &rest[..close];
(!name.is_empty()).then(|| name.to_string())
}
/// The macro's bound spell id — [`cast_name`] resolved against the player's book by the same law
/// `CastSpellByName` itself uses ([`resolve_spell_by_name`]), so the icon's cooldown swirl and the
/// press always agree about which rank is meant. `None` for a macro that casts nothing, or names a
/// spell this character does not know: the slot then reports no cooldown and no range, which is
/// what the reference's `0x4e5a50` produces for a `[rec+0x564]` of 0.
pub(crate) fn bound_spell(table: &SlashCommands, body: &str, book: &SpellBookState) -> Option<u32> {
let name = cast_name(table, body)?;
resolve_spell_by_name(book, &name).map(|s| s.spell_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_chat::commands::SlashCommands;
/// A table with just the aliases these tests need, built the way boot builds it (through the
/// global-string reader), so the parse is exercised against a real lookup and never a literal.
fn table() -> SlashCommands {
SlashCommands::build(
|name| match name {
"SLASH_CAST1" => Some("/cast".into()),
"SLASH_CAST2" => Some("/spell".into()),
"SLASH_SCRIPT1" => Some("/script".into()),
"SLASH_TARGET1" => Some("/target".into()),
_ => None,
},
|_| None,
)
}
#[test]
fn macro_lines_trims_blanks_and_windows_line_endings() {
let body = "/cast Fireball\r\n\r\n /say pew \n";
let lines: Vec<&str> = macro_lines(body).collect();
assert_eq!(lines, ["/cast Fireball", "/say pew"]);
}
/// The `/cast` form, through the ALIAS table — `/spell` is `SLASH_CAST2` in the shipped
/// strings, so it must bind exactly as `/cast` does.
#[test]
fn cast_name_reads_the_first_cast_line_through_the_alias_table() {
let t = table();
assert_eq!(
cast_name(&t, "/target Bob\n/cast Fireball\n/say pew"),
Some("Fireball".into())
);
assert_eq!(cast_name(&t, "/spell Frostbolt"), Some("Frostbolt".into()));
// The whole argument, subtext included — `resolve_spell_by_name` owns that grammar.
assert_eq!(
cast_name(&t, "/cast Fireball(Rank 1)"),
Some("Fireball(Rank 1)".into())
);
// First match wins.
assert_eq!(
cast_name(&t, "/cast Fireball\n/cast Frostbolt"),
Some("Fireball".into())
);
// A bare `/cast` binds nothing and does not stop the walk.
assert_eq!(
cast_name(&t, "/cast\n/cast Frostbolt"),
Some("Frostbolt".into())
);
assert_eq!(cast_name(&t, "/say hello\n/target Bob"), None);
}
/// The `CastSpellByName(` form — the other half of the reference's own two-literal parse.
#[test]
fn cast_name_reads_a_quoted_cast_spell_by_name_call() {
let t = table();
assert_eq!(
cast_name(&t, r#"/script CastSpellByName("Shadow Bolt")"#),
Some("Shadow Bolt".into())
);
// Spacing and a second argument don't matter; the first quoted argument is the name.
assert_eq!(
cast_name(&t, r#"/script CastSpellByName( "Healing Touch", 1 )"#),
Some("Healing Touch".into())
);
// A computed argument has no name to bind — neither here nor in the reference.
assert_eq!(cast_name(&t, "/script CastSpellByName(spell)"), None);
assert_eq!(cast_name(&t, r#"/script CastSpellByName("")"#), None);
}
/// The bound spell is resolved by the SAME law the press uses, so the swirl and the cast can
/// never disagree about the rank.
#[test]
fn bound_spell_resolves_through_the_book() {
use benilla_ui::script::SpellSlotView;
let book = SpellBookState {
tabs: Vec::new(),
slots: vec![
SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
rank: Some("Rank 1".into()),
..Default::default()
},
SpellSlotView {
spell_id: 145,
name: "Fireball".into(),
rank: Some("Rank 2".into()),
..Default::default()
},
],
};
let t = table();
// No subtext -> the highest known rank.
assert_eq!(bound_spell(&t, "/cast Fireball", &book), Some(145));
// A pinned subtext -> that rank, both spacings.
assert_eq!(bound_spell(&t, "/cast Fireball(Rank 1)", &book), Some(133));
assert_eq!(bound_spell(&t, "/cast Fireball (Rank 1)", &book), Some(133));
// Unknown spell / no cast line -> nothing bound (the slot reports no cooldown).
assert_eq!(bound_spell(&t, "/cast Pyroblast", &book), None);
assert_eq!(bound_spell(&t, "/say hi", &book), None);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! The macro **file format** — the reference client's own, byte-for-byte (decision 0983).
//!
//! The 1.12 client writes each macro with the format string **`MACRO %d "%s" %s`** (byte-read at
//! `0x44cb60`, inside the `UIMacros.cpp` string block) followed by the body's lines and a bare
//! `END`, into `macros-cache.txt`/`macros-local.txt` (`0x45de74`/`0x45de88`). A real file off the
//! director's own install:
//!
//! ```text
//! MACRO 1 "f" Ability_Ambush
//! .cheat fly on
//! .modify aspeed 10
//! END
//! ```
//!
//! benilla writes exactly that. Not nostalgia: it means **a player can drop their vanilla
//! `macros-cache.txt` into `benilla/macros/` and their macros are there** — and it keeps the file
//! hand-editable, which a serialized blob would not be. The icon column is the texture's *basename*
//! (`Ability_Ambush`); the client prepends `Interface\Icons\` (`0x44ca64`), which is why the stored
//! token survives an icon list changing under it where a chooser index would silently re-point.
//!
//! Two liberties, both taken because the reference's own writer can produce files its own reader
//! must survive: an index is trusted only as an *ordering key* (the lists are dense — see
//! `benilla_ui::script::macros`), and a name containing a `"` round-trips (the reference's `"%s"`
//! is unescaped, so we take the span between the FIRST and LAST quote on the line, which is what
//! its own writer would have produced).
use benilla_ui::script::{MacroView, MAX_MACROS};
/// The path prefix the client prepends to a stored icon token (`0x44ca64`).
pub(super) const ICON_PREFIX: &str = "Interface\\Icons\\";
/// Parse one scope's file into a dense macro list, longest-effort: a malformed record is skipped
/// with the rest kept, because losing every macro to one bad line is the worse failure. Records
/// beyond [`MAX_MACROS`] are dropped (the tab cannot show them).
pub(super) fn parse(text: &str) -> Vec<MacroView> {
let mut out: Vec<(u32, MacroView)> = Vec::new();
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let Some(rest) = line.trim_start().strip_prefix("MACRO ") else {
continue; // stray text between records (or a leading blank) — skipped
};
let Some((index, name, icon)) = parse_header(rest) else {
continue;
};
// Body: every line up to the bare END (or the next MACRO / EOF, so a file truncated
// mid-record still yields the macro it was writing).
let mut body: Vec<&str> = Vec::new();
loop {
match lines.peek() {
None => break,
Some(l) if l.trim() == "END" => {
lines.next();
break;
}
Some(l) if l.trim_start().starts_with("MACRO ") => break,
Some(_) => body.push(lines.next().unwrap_or_default()),
}
}
out.push((
index,
MacroView {
name,
texture: icon.map(icon_path),
body: body.join("\n"),
local_only: false,
},
));
}
out.sort_by_key(|(i, _)| *i);
out.into_iter().map(|(_, m)| m).take(MAX_MACROS).collect()
}
/// `<index> "<name>" <icon>` — the tail of a `MACRO ` header line.
fn parse_header(rest: &str) -> Option<(u32, String, Option<String>)> {
let (index, rest) = rest.split_once('"')?;
let index: u32 = index.trim().parse().ok()?;
// The name runs to the LAST quote on the line: the reference's `"%s"` is unescaped, so a name
// holding a quote is only recoverable from the outside in.
let close = rest.rfind('"')?;
let name = rest[..close].to_string();
let icon = rest[close + 1..].trim();
Some((index, name, (!icon.is_empty()).then(|| icon.to_string())))
}
/// Serialize a dense macro list back into the reference's format.
pub(super) fn write(macros: &[MacroView]) -> String {
let mut out = String::new();
for (i, m) in macros.iter().enumerate() {
let icon = m.texture.as_deref().map(icon_token).unwrap_or_default();
out.push_str(&format!("MACRO {} \"{}\" {}\n", i + 1, m.name, icon));
if !m.body.is_empty() {
out.push_str(&m.body);
out.push('\n');
}
out.push_str("END\n");
}
out
}
/// A stored icon token → the full texture path. A token that already carries a path separator is
/// taken whole (a hand-edited file naming art outside `Interface\Icons`).
fn icon_path(token: String) -> String {
if token.contains('\\') || token.contains('/') {
token
} else {
format!("{ICON_PREFIX}{token}")
}
}
/// The inverse: a full texture path → the stored token (the basename, when it sits in the icons
/// folder; the whole path otherwise, so a hand-edited entry round-trips).
fn icon_token(path: &str) -> &str {
path.strip_prefix(ICON_PREFIX).unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
/// The **real file** off the director's 1.12 install (`WTF/Account/WINUSER/macros-cache.txt`),
/// verbatim — the one input that proves this reader against the reference's own writer. Note
/// the out-of-order indices: the file is a dump, the index column is the ordering key.
const REAL_112_FILE: &str = "MACRO 1 \"f\" Ability_Ambush\n\
.cheat fly on\n\
.modify aspeed 10\n\
END\n\
MACRO 3 \"ns\" Ability_BackStab\n\
.cheat fly off\n\
.modify aspeed 1\n\
END\n\
MACRO 2 \"w\" Ability_Creature_Cursed_05\n\
.wchange 0 0\n\
END\n";
#[test]
fn a_real_1_12_macros_cache_file_parses_in_index_order() {
let macros = parse(REAL_112_FILE);
assert_eq!(macros.len(), 3);
// Sorted by the index COLUMN, not by file position (the real file is 1, 3, 2).
let names: Vec<&str> = macros.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["f", "w", "ns"]);
assert_eq!(
macros[0].texture.as_deref(),
Some("Interface\\Icons\\Ability_Ambush"),
"the stored token is a basename; the client's own prefix completes it"
);
assert_eq!(macros[0].body, ".cheat fly on\n.modify aspeed 10");
assert_eq!(macros[1].body, ".wchange 0 0");
}
#[test]
fn write_then_parse_round_trips_and_matches_the_reference_shape() {
let macros = parse(REAL_112_FILE);
let text = write(&macros);
// The reference's own line shape, with the list re-densified 1..n.
assert!(text.starts_with("MACRO 1 \"f\" Ability_Ambush\n"));
assert!(text.contains("\nMACRO 2 \"w\" Ability_Creature_Cursed_05\n"));
assert!(text.ends_with("END\n"));
assert_eq!(parse(&text), macros, "round trip");
}
#[test]
fn an_empty_body_and_a_missing_icon_survive_the_round_trip() {
let m = vec![MacroView {
name: "bare".into(),
texture: None,
body: String::new(),
local_only: false,
}];
let text = write(&m);
assert_eq!(text, "MACRO 1 \"bare\" \nEND\n");
assert_eq!(parse(&text), m);
}
/// Longest-effort parsing: a malformed record loses only itself, and a file truncated
/// mid-record (a crash between the body and its `END`) still yields what was written.
#[test]
fn malformed_records_lose_only_themselves() {
let text = "garbage line\n\
MACRO notanumber \"x\" Icon\n\
END\n\
MACRO 2 \"good\" Ability_Ambush\n\
/say hi\n\
END\n\
MACRO 3 \"truncated\" Ability_Ambush\n\
/say bye\n";
let macros = parse(text);
assert_eq!(macros.len(), 2);
assert_eq!(macros[0].name, "good");
assert_eq!(macros[1].name, "truncated");
assert_eq!(macros[1].body, "/say bye", "no END is still a macro");
}
/// A name holding a quote round-trips, because the reference's unescaped `"%s"` means the
/// only recoverable reading is first-quote-to-last-quote.
#[test]
fn a_quoted_name_round_trips_the_way_the_reference_would_write_it() {
let m = vec![MacroView {
name: "say \"hi\"".into(),
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
body: "/say hi".into(),
local_only: false,
}];
assert_eq!(parse(&write(&m)), m);
}
/// A hand-edited entry naming art outside `Interface\Icons` keeps its whole path.
#[test]
fn a_full_path_icon_token_is_taken_whole() {
let text = "MACRO 1 \"custom\" Interface\\Buttons\\UI-Panel-Button\nEND\n";
let macros = parse(text);
assert_eq!(
macros[0].texture.as_deref(),
Some("Interface\\Buttons\\UI-Panel-Button")
);
assert_eq!(write(&macros), text, "and writes back unchanged");
}
/// A file with more records than a tab can hold is truncated, never wrapped into the other
/// tab (the two scopes are separate files).
#[test]
fn a_file_longer_than_the_tab_is_truncated() {
let text: String = (1..=25)
.map(|i| format!("MACRO {i} \"m{i}\" Ability_Ambush\nEND\n"))
.collect();
assert_eq!(parse(&text).len(), MAX_MACROS);
}
}
+148
View File
@@ -0,0 +1,148 @@
//! The app half of the macro system (decision 0983): persistence under `benilla/macros/`, the
//! runner's route into the chat drain, and the seed/dirty contract the plugin's systems rely on.
//!
//! The file FORMAT has its own tests in [`super::store`] (including the director's real 1.12
//! `macros-cache.txt`); the API's own round trip is `benilla_ui::script::macros`'; the window's is
//! `crate::ui_script::macro_tests`. What is only testable here is the wiring.
use benilla_ui::script::{MacroState, MacroView, UiScript};
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
fn macro_view(name: &str, body: &str) -> MacroView {
MacroView {
name: name.into(),
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
body: body.into(),
local_only: false,
}
}
/// A save writes the reference's own format under `benilla/macros/`, and a load brings the same
/// macros back — the whole persistence loop over the real `local_state` law.
#[test]
fn a_saved_macro_table_round_trips_through_benilla_macros() {
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-macros-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let account = crate::local_state::macros_account_path().unwrap();
let character = crate::local_state::macros_character_path("Test Realm", "Probeone").unwrap();
let state = MacroState {
account: vec![macro_view("Ambush", "/cast Ambush\n/say pew")],
character: vec![macro_view("Charge", "/cast Charge")],
};
crate::local_state::write_atomic(&account, &super::store::write(&state.account)).unwrap();
crate::local_state::write_atomic(&character, &super::store::write(&state.character)).unwrap();
// The file on disk is the reference's own shape — readable, hand-editable, and the exact
// format a vanilla `macros-cache.txt` already has.
assert_eq!(
std::fs::read_to_string(&account).unwrap(),
"MACRO 1 \"Ambush\" Ability_Ambush\n/cast Ambush\n/say pew\nEND\n"
);
let back = MacroState {
account: super::store::parse(&std::fs::read_to_string(&account).unwrap()),
character: super::store::parse(&std::fs::read_to_string(&character).unwrap()),
};
assert_eq!(back, state);
std::fs::remove_dir_all(&tmp).ok();
}
/// A capture run is hermetic (decision 0954): both paths resolve to `None`, so a macro edit during
/// a capture is session-only and nothing is written under anyone's install.
#[test]
fn a_capture_run_persists_nothing() {
let _l = ENV_LOCK.lock().unwrap();
let _h = EnvGuard::set("BENILLA_HOME", "/tmp/benilla-should-not-exist");
let _c = EnvGuard::set("WOW_CAPTURE", "ui-macro");
assert_eq!(crate::local_state::macros_account_path(), None);
assert_eq!(crate::local_state::macros_character_path("R", "C"), None);
}
/// The runner pushes every body line onto the chat-input queue in order — the door a typed line
/// comes through, which is what makes `/cast`, `/target`, `/script`, the chat types and the 225
/// emotes all work in a macro without the runner knowing any of them.
#[test]
fn running_a_macro_queues_its_lines_as_chat_input() {
let mut s = UiScript::new().unwrap();
s.set_macros(MacroState {
account: vec![macro_view(
"Ambush",
"/cast Ambush\n\n /say pew \n/target Bob",
)],
character: Vec::new(),
});
assert!(super::run_macro(&mut s, 1));
assert_eq!(
s.take_chat_input(),
vec!["/cast Ambush", "/say pew", "/target Bob"],
"blank lines dropped, each line trimmed, order kept"
);
// An empty macro and an empty slot both run nothing and queue nothing.
s.set_macros(MacroState {
account: vec![macro_view("Blank", " \n\n")],
character: Vec::new(),
});
assert!(!super::run_macro(&mut s, 1));
assert!(!super::run_macro(&mut s, 7));
assert!(s.take_chat_input().is_empty());
}
/// A CHARACTER-range macro runs by its own index — the second half of the space is not a special
/// case anywhere in the runner.
#[test]
fn a_character_macro_runs_by_its_own_index() {
let mut s = UiScript::new().unwrap();
s.set_macros(MacroState {
account: Vec::new(),
character: vec![macro_view("Charge", "/cast Charge")],
});
assert!(super::run_macro(&mut s, 19));
assert_eq!(s.take_chat_input(), vec!["/cast Charge"]);
}
/// The seed→dirty→save contract the plugin's two systems rest on: the app's own load must not look
/// like a change (or every login would rewrite the file), and every script mutation must.
#[test]
fn the_dirty_edge_distinguishes_a_load_from_an_edit() {
let mut s = UiScript::new().unwrap();
s.set_macros(MacroState {
account: vec![macro_view("Ambush", "/cast Ambush")],
character: Vec::new(),
});
assert!(
!s.take_macros_dirty(),
"loading from disk is not an edit — a save here would be a write-back loop"
);
s.run(r#"EditMacro(1, nil, nil, "/cast Backstab")"#)
.unwrap();
assert!(s.take_macros_dirty());
assert_eq!(s.macros().account[0].body, "/cast Backstab");
}
/// The generation counter is the per-frame consumers' gate (the action bar's identity feed): it
/// moves on a seed AND on every mutation, and is never consumed by reading it.
#[test]
fn the_generation_moves_on_every_write_and_is_not_drained() {
let mut s = UiScript::new().unwrap();
let at_start = s.macros_generation();
assert_eq!(s.macros_generation(), at_start, "reading never drains it");
s.set_macros(MacroState {
account: vec![macro_view("Ambush", "/cast Ambush")],
character: Vec::new(),
});
let after_seed = s.macros_generation();
assert_ne!(after_seed, at_start, "a seed changes the bar's icons too");
s.run(r#"EditMacro(1, "Renamed", 1)"#).unwrap();
assert_ne!(s.macros_generation(), after_seed);
}
+812
View File
@@ -0,0 +1,812 @@
//! The pet action bar's feed + drain — the app side of `benilla_ui::script::pet`'s seam
//! (decision 0982), and the home of [`PetBar`], the server-authoritative state
//! `net/apply/pet.rs` writes.
//!
//! **The shape of this system is the stance bar's mirror image, and that is the thing to hold on
//! to.** `crate::ui_shapeshift` derives its whole bar locally, from the known-spell set × the
//! `Spell.dbc` catalog, and the server never has an opinion about it. Here the server owns the
//! bar's CONTENTS — which slots exist and what is in them — and hands them over whole in
//! `SMSG_PET_SPELLS`. So the feed does no admission and no ordering: it *renders* the ten words
//! the last packet delivered.
//!
//! **But the bar's STATE — the lit command, the lit reaction, the autocast bit — is applied
//! LOCALLY, and that is forced rather than chosen.** The server answers none of those three
//! presses: `HandlePetAction`'s command and reaction arms send nothing back,
//! `HandlePetSpellAutocastOpcode` sets its bits and returns, and `SMSG_PET_MODE` is emitted from
//! exactly one place in the whole server (`Pet::SetEnabled` — the enabled flag, nothing else). A
//! client that waited for confirmation would show a bar whose reaction buttons never light and
//! whose autocast ring never appears. Verified live against vmangos, 2026-08-05: a
//! `TogglePetAutocast` and a Follow press drew no reply packet at all. [`drain_pet_actions`]
//! carries that half, and the next `SMSG_PET_SPELLS` re-authorities it.
//!
//! **The slot law** (decode and constants in `benilla_protocol::messages::pet`, VERIFIED against
//! the client itself in decision 0988): each slot is one packed word whose type — bits 24–29,
//! **masked** — decides its whole reading:
//!
//! - `PET_ACT_COMMAND 7` — `action` is a `CommandStates`: Stay 0 / Follow 1 / Attack 2 / Dismiss 3.
//! A token: `GetPetActionInfo` returns the NAMES of two globals rather than values (the
//! reference's own `PetActionBarFrame.lua:98-104` fork), which `PetActionBar.xml` resolves —
//! `PET_ACTION_*` out of the shipped `GlobalStrings.lua`, `PET_*_TEXTURE` out of the constants
//! that file declares and ours quotes.
//! - `PET_ACT_REACTION 6` — `action` is a `ReactStates`: Passive 0 / Defensive 1 / Aggressive 2.
//! Also a token.
//! - types **1–5** — `action` is a spell id, and the autocast pair rides in bits 31/30 rather than
//! in the type. That split is why the server's `ACT_PASSIVE`/`ACT_DISABLED`/`ACT_ENABLED` are
//! one branch here and not three.
//!
//! The two halves of `GetPetActionInfo`'s signature never overlap: `isActive` is a **token**
//! concept and `autoCast*` a **spell** one, each nil on the other's branch.
//!
//! The **3 commands / 4 spells / 3 reactions** arrangement everyone recognises is therefore
//! *server data*, not a layout: it is what `CharmInfo::InitPetActionBar` happens to write, and a
//! possessed minion or a charmed creature arrives with a different mix through the same ten words.
//! Nothing in this file or in `PetActionBar.xml` assumes it.
use std::time::Instant;
use bevy::prelude::*;
use benilla_protocol::messages::{
PetActionEntry, PetSpells, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW,
PET_COMMAND_STAY, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
};
use benilla_ui::script::{PetActionView, UiScript};
use crate::cooldowns::Cooldowns;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
use crate::target::Selection;
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// The pet action bar's mirror of the server's state — written only by `net/apply/pet.rs`, read
/// only by [`feed_pet_bar`] and [`drain_pet_actions`].
///
/// `spells.pet_guid == 0` is "there is no pet bar", and it is the single test: the teardown packet
/// carries exactly that and nothing else.
#[derive(Resource, Default)]
pub(crate) struct PetBar {
/// The last `SMSG_PET_SPELLS` in full, with `SMSG_PET_MODE`'s state edits folded in.
pub(crate) spells: PetSpells,
/// The **pet's own** cooldown store — a second [`Cooldowns`] instance, not the player's. The
/// two must not share: a hunter and their pet both know Growl-shaped spells on independent
/// timers, and the reference keeps a `SPELLHISTORY` list per unit for exactly this reason.
/// 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).
///
/// It has exactly three writers, and reproducing them is the whole of `IsPetAttackActive`:
/// set by an ATTACK command press (`0x4bd42e`), cleared by `PetStopAttack` (`0x4bd6ae`), and
/// cleared on **any** pet-guid change (`0x4bc8ce`) — a new pet is not attacking.
///
/// 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.
pub(crate) attacking: bool,
}
impl PetBar {
/// `PetHasActionBar()` — is there a bar at all. The client's own gate is exactly this, a
/// nonzero cached pet guid: no alive check, no control check (wow-re §3).
pub(crate) fn has_bar(&self) -> bool {
self.spells.pet_guid != 0
}
}
/// `GetPetActionsUsable()` — may the bar be used at all (wow-re §4, the predicate `0x4bcf70`).
///
/// benilla's earlier reading — "the enabled-flags byte's `0x8`" — was REFUTED by the RE as the
/// *whole* answer, but it survives as one of the seven steps: the client tests bit 27 of the state
/// dword, which is that same byte's `0x8` (see `PET_STATE_BAR_DISABLED`). The step it was missing
/// is the pet's own crowd-control state — a **stunned, confused or feared** pet cannot be ordered,
/// and its bar greys until it recovers.
///
/// The remaining four steps are ownership identity (the player resolves, the pet resolves, the pet
/// is ours, we are not ourselves charmed). Holding a bar at all already means the server named us
/// this pet's controller, so they are structurally true here; the two that can actually change
/// while a bar is on screen are the two we test.
///
/// This is the same predicate that gates whether a press does anything, so a false answer greys
/// the icons *and* is the honest reason a click would be ignored.
fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
!bar.spells.bar_disabled()
&& pet_flags.is_none_or(|f| f & benilla_protocol::messages::PET_UNUSABLE_UNIT_FLAGS == 0)
}
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a real change rather than every frame
/// (the [`crate::ui_shapeshift`] memory pattern).
#[derive(Default)]
struct PetBarMemory {
pushed: Option<(bool, bool, Vec<PetActionView>)>,
}
pub(crate) struct UiPetPlugin;
impl Plugin for UiPetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PetBar>().add_systems(
Update,
(
// The feed rides with the unit feed (before the VM ticks, like the stance bar's);
// the drain runs after the input pass so a click goes out the same frame.
feed_pet_bar.in_set(UnitFeed).before(UiInput),
drain_pet_actions.after(UiInput),
),
);
}
}
/// The command tokens' `(GlobalStrings key, texture-global name)` pair.
///
/// Both are returned as **the names of globals**, not values — the reference's token convention.
/// The name keys ship in `GlobalStrings.lua` (`PET_ACTION_ATTACK` = "Attack" at l.3029-3032); the
/// texture globals are declared by `PetActionBar.xml`, quoting the reference's
/// `PetActionBarFrame.lua:6-12` verbatim.
fn command_token(action: u32) -> Option<(&'static str, &'static str)> {
Some(match action {
PET_COMMAND_STAY => ("PET_ACTION_WAIT", "PET_WAIT_TEXTURE"),
PET_COMMAND_FOLLOW => ("PET_ACTION_FOLLOW", "PET_FOLLOW_TEXTURE"),
PET_COMMAND_ATTACK => ("PET_ACTION_ATTACK", "PET_ATTACK_TEXTURE"),
PET_COMMAND_DISMISS => ("PET_ACTION_DISMISS", "PET_DISMISS_TEXTURE"),
_ => return None,
})
}
/// The reaction tokens' pair, same convention. `PET_MODE_*` ship in `GlobalStrings.lua`
/// (l.3045-3047) — deliberately the `PET_MODE_*` keys, which name the state the pet is IN, not the
/// `PET_AGGRESSIVE`/`PET_DEFENSIVE`/`PET_PASSIVE` keys, which are the right-click menu's
/// imperatives. Both read identically in enUS ("Aggressive"), so only a localized client can tell
/// them apart — which is exactly why the key matters rather than the string.
fn reaction_token(action: u32) -> Option<(&'static str, &'static str)> {
Some(match action {
PET_REACT_PASSIVE => ("PET_MODE_PASSIVE", "PET_PASSIVE_TEXTURE"),
PET_REACT_DEFENSIVE => ("PET_MODE_DEFENSIVE", "PET_DEFENSIVE_TEXTURE"),
PET_REACT_AGGRESSIVE => ("PET_MODE_AGGRESSIVE", "PET_AGGRESSIVE_TEXTURE"),
_ => return None,
})
}
/// Resolve one packed slot word into what the bar draws.
///
/// `cooldown` is passed in rather than looked up here so the whole function stays a pure
/// (state, word) → view mapping, which is what the tests below exercise.
fn slot_view(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
) -> PetActionView {
let kind = entry.kind();
let action = entry.action();
if let Some((name, texture)) = (kind == benilla_protocol::messages::PET_ACT_COMMAND)
.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`:
//
// - 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_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.
let attacking = pet_attacking && action == PET_COMMAND_ATTACK;
return PetActionView {
name: Some(name.to_string()),
texture: Some(texture.to_string()),
is_token: true,
active: bar.command_state() == action || attacking,
attack_active: attacking,
..Default::default()
};
}
if let Some((name, texture)) = (kind == benilla_protocol::messages::PET_ACT_REACTION)
.then(|| reaction_token(action))
.flatten()
{
// The reaction compare's left side is forced to Passive when the bar is disabled
// (wow-re §2.2, `0x4bde3c`): a pet that cannot be ordered reads as Passive rather than
// keeping the mode light it had, which is the honest thing for it to say.
let showing = if bar.bar_disabled() {
benilla_protocol::messages::PET_REACT_PASSIVE
} else {
bar.react_state()
};
return PetActionView {
name: Some(name.to_string()),
texture: Some(texture.to_string()),
is_token: true,
active: showing == action,
..Default::default()
};
}
// A spell slot. `is_empty` is the zero WORD (the client tests the dword); vmangos's own unused
// middle slots are not zero and arrive here instead, where their spell id 0 misses the catalog
// and takes the same exit — the client's own route to the same empty button.
if !entry.is_spell() || entry.is_empty() {
return PetActionView::default();
}
let Some(spell) = spell else {
// The catalog failed to load, or the server named a spell 5875's DBC does not have. Draw
// the slot as occupied but nameless rather than inventing a name: the button then hides,
// which is honest, and the alternative (a "?" with no tooltip) has fooled nobody.
return PetActionView::default();
};
PetActionView {
name: Some(spell.name.clone()),
subtext: spell.rank.clone(),
texture: spell.icon.clone(),
is_token: false,
spell_id: Some(action),
// A spell slot NEVER reports isActive — VERIFIED nil on every path (wow-re §2.1, pushed
// at `0x4bdd5e`), which retires 0982's INTERIM. `isActive` is exclusively a token
// concept and `autoCast*` exclusively a spell one; the two halves of the signature never
// overlap (§2.5 quirk 3).
//
// The client does express "the pet is running this spell", but as an ICON swap, not a
// ring: `GetPetActionInfo` substitutes the record's `ActiveIconID` for its `SpellIconID`
// when the spell is a live aura on the pet with its aura-flag bit set (`0x4bcea0`). That
// needs an aura-indexed icon we do not carry yet — named in decision 0988, not faked here.
active: false,
// Autocast is bits 31/30 of the word, not the type byte (wow-re §2.1) — and both are
// additionally gated on the spell resolving in `Spell.dbc`, which the early return above
// has already enforced by the time we get here.
autocast_allowed: entry.autocast_allowed(),
autocast_enabled: entry.autocast_on(),
attack_active: false,
cooldown,
}
}
/// Rebuild the ten slot views each frame and diff-push them, firing `PET_BAR_UPDATE` on a change.
///
/// One event where the reference has four (`PET_BAR_UPDATE`, `PET_BAR_UPDATE_COOLDOWN`,
/// `UNIT_PET`, and the `UNIT_FLAGS`/`UNIT_AURA` pair its bar filters for `arg1 == "pet"`) — the
/// deliberate collapse `crate::ui_shapeshift` already makes for the stance bar, and for the same
/// reason: we diff the whole pushed state, so one event carries every change there can be.
#[allow(clippy::too_many_arguments)]
fn feed_pet_bar(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
spells: Option<Res<Spells>>,
clock: Res<crate::ui_script::UiClock>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
mut memory: Local<PetBarMemory>,
) {
let Some(mut script) = script else {
return;
};
let now = Instant::now();
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
let has_bar = bar.has_bar();
// The pet's own `UNIT_FIELD_FLAGS`, for the crowd-control half of the usability predicate.
// `None` = we hold a bar for a unit whose descriptor has not arrived (or has left); the
// predicate then rests on bit 27 alone rather than greying a bar on missing data.
let pet_flags = index
.0
.get(&bar.spells.pet_guid)
.and_then(|&e| stores.get(e).ok())
.map(|s| s.0.unit_flags());
let usable = actions_usable(&bar, pet_flags);
let pet_attacking = bar.attacking;
let fresh: Vec<PetActionView> = if has_bar {
bar.spells
.bar
.iter()
.map(|&entry| {
let display = entry
.is_spell()
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
.flatten();
let cooldown = display.and_then(|d| {
bar.cooldowns
.info(entry.action(), 0, Some(d), now)
.ui_triple(anchor, ui_now)
});
slot_view(entry, &bar.spells, display, cooldown, pet_attacking)
})
.collect()
} else {
Vec::new()
};
if memory.pushed.as_ref() != Some(&(has_bar, usable, fresh.clone())) {
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" },
);
memory.pushed = Some((has_bar, usable, fresh.clone()));
script.set_pet_actions(has_bar, usable, fresh);
script.fire_event("PET_BAR_UPDATE", vec![]);
}
}
/// Drain the bar's three intents onto the wire.
///
/// Every one carries the pet's guid from [`PetBar`] — never a guid the VM supplied — so an intent
/// queued in the frame a pet was dismissed dies here rather than naming a unit that is gone.
///
/// `CMSG_PET_ACTION` echoes the slot's **own packed word**: the server re-splits it and dispatches
/// on the type byte, so command, reaction and cast all leave through this one send. The target is
/// our current selection, which is what makes "select a mob, press Attack" work; a slot that wants
/// no target simply has the server ignore it (`HandlePetAction`'s `explicitlySelectedTarget` fork
/// drops a target the spell does not want).
///
/// **This drain is also where the bar's STATE moves, and that is not an optimisation — it is
/// forced.** The server answers none of these three: `HandlePetAction`'s command and reaction arms
/// send nothing back, and `HandlePetSpellAutocastOpcode` sets its bits and returns
/// (`PetHandler.cpp:451-478`). `SMSG_PET_MODE` is emitted from exactly ONE place in the whole
/// server — `Pet::SetEnabled`, i.e. only when the bar is enabled or disabled
/// (`Pet.cpp:2362-2377`). So a client that waited for confirmation would show a pet bar whose
/// reaction buttons never light and whose autocast ring never appears; the reference plainly does
/// not, so it must apply all three locally, and so do we. Verified live, 2026-08-05: a
/// `TogglePetAutocast` + a Follow press against vmangos drew no packet whatsoever in reply.
///
/// 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.
fn drain_pet_actions(
script: Option<NonSendMut<UiScript>>,
mut bar: ResMut<PetBar>,
selection: Res<Selection>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
let pressed = script.take_pet_actions();
let toggles = script.take_pet_autocast_toggles();
let stops = script.take_pet_stop_attacks();
if pressed.is_empty() && toggles.is_empty() && stops == 0 {
return;
}
let pet_guid = bar.spells.pet_guid;
if pet_guid == 0 {
debug!("ui_pet: dropping queued pet intents — the bar is gone");
return;
}
let target_guid = selection.guid.unwrap_or(0);
for slot in pressed {
let Some(entry) = slot_entry(&bar, slot) else {
continue;
};
debug!(
"ui_pet: press slot {slot} (action {} kind {:#04x}) at {target_guid:#x}",
entry.action(),
entry.kind()
);
let _ = commands.0.send(ClientCommand::PetAction {
pet_guid,
packed: entry.packed,
target_guid,
});
latch_press(&mut bar, entry);
}
for slot in toggles {
let Some(entry) = slot_entry(&bar, slot).filter(|e| e.autocast_allowed()) else {
continue;
};
// The client flips bit 30 in the slot word IN PLACE and sends the whole new word — it is
// not a "set autocast to X for spell Y" verb (wow-re §10.2, `0x4bcbff`/`0x4bcc17`). The
// server reads the direction back out of the type byte it arrives in.
let flipped = entry.with_autocast(!entry.autocast_on());
debug!(
"ui_pet: autocast {} for spell {} (slot {slot})",
flipped.autocast_on(),
entry.action()
);
let _ = commands.0.send(ClientCommand::PetSetAction {
pet_guid,
// The wire's slot positions are 0-based (vmangos bounds them `< 10`); the Lua's are
// 1-based. This subtraction is the whole of the conversion and cannot underflow —
// `slot_entry` already rejected 0.
entries: vec![(slot - 1, flipped.packed)],
});
if let Some(e) = slot_entry_mut(&mut bar, slot) {
*e = flipped;
}
}
for _ in 0..stops {
// `PetStopAttack` no-ops entirely when the pet is not attacking (`0x4bd65e`) — no packet,
// no repaint. That gate lives here rather than in the VM because the latch does.
if !bar.attacking {
continue;
}
debug!("ui_pet: stop attack");
let _ = commands.0.send(ClientCommand::PetStopAttack { pet_guid });
bar.attacking = false;
}
}
/// Latch a pressed slot's state locally — the half the server never confirms (the module doc's
/// "forced rather than chosen"), now with the client's own masks rather than byte assignments.
///
/// Both writes are exact transcriptions, and the masks are the point:
///
/// - **reaction** (`0x4bc94c`): `state = state & 0xFFFFFF00 | action` — byte 0 only.
/// - **command** (`0x4bc96f`): `state = state & 0x080000FF | action << 8` — bits 8–15, keeping
/// byte 0 and **bit 27**. The client deliberately preserves the disabled bit while rewriting the
/// command, which is also the proof that bit 27 is server-owned: no client path writes it.
///
/// DISMISS is deliberately not latched: it ends the pet rather than becoming its standing command,
/// and the teardown packet clears the whole bar a moment later. ATTACK additionally raises the
/// attack latch (`0x4bd42e`), which is what lights the button and arms the call-off fork.
fn latch_press(bar: &mut PetBar, entry: PetActionEntry) {
match entry.kind() {
benilla_protocol::messages::PET_ACT_COMMAND if entry.action() != PET_COMMAND_DISMISS => {
bar.spells.state = (bar.spells.state & 0x0800_00FF) | (entry.action() << 8);
if entry.action() == PET_COMMAND_ATTACK {
bar.attacking = true;
}
}
benilla_protocol::messages::PET_ACT_REACTION => {
bar.spells.state = (bar.spells.state & 0xFFFF_FF00) | entry.action();
}
_ => {}
}
}
/// The 1-based Lua slot index → the packed word we still hold for it.
fn slot_entry(bar: &PetBar, slot: u32) -> Option<PetActionEntry> {
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
bar.spells.bar.get(index).copied()
}
/// [`slot_entry`]'s mutable twin, for the autocast flip's write-back.
fn slot_entry_mut(bar: &mut PetBar, slot: u32) -> Option<&mut PetActionEntry> {
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
bar.spells.bar.get_mut(index)
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{
PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION,
PET_STATE_BAR_DISABLED,
};
fn packed(action: u32, kind: u8) -> PetActionEntry {
PetActionEntry::from(action | (u32::from(kind) << 24))
}
fn spell(name: &str, rank: Option<&str>) -> benilla_formats::SpellDisplay {
benilla_formats::SpellDisplay {
name: name.to_string(),
rank: rank.map(str::to_string),
icon: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
..Default::default()
}
}
/// The state dword as the server packs it: react in byte 0, command in byte 1.
fn state(command: u32, react: u32) -> PetSpells {
PetSpells {
pet_guid: 0x2A,
state: react | (command << 8),
..Default::default()
}
}
/// A command token returns GLOBAL NAMES for both name and texture, and is lit exactly when it
/// IS the pet's current command — the whole reason three command buttons can share one state.
#[test]
fn command_tokens_light_on_the_current_command() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let follow = slot_view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&bar,
None,
None,
false,
);
assert_eq!(follow.name.as_deref(), Some("PET_ACTION_FOLLOW"));
assert_eq!(follow.texture.as_deref(), Some("PET_FOLLOW_TEXTURE"));
assert!(follow.is_token && follow.active);
assert!(!follow.attack_active, "Follow is not the attack fork");
let stay = slot_view(
packed(PET_COMMAND_STAY, PET_ACT_COMMAND),
&bar,
None,
None,
false,
);
assert!(!stay.active, "only the CURRENT command is lit");
}
/// The Attack button's second clause: the attack latch lights it and arms the call-off fork
/// **independently of the command state**, which is how a pet told to Follow can still show
/// Attack lit while it is on something.
#[test]
fn the_attack_latch_lights_attack_whatever_the_command_state_says() {
let following = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let idle = slot_view(attack, &following, None, None, false);
assert!(!idle.active && !idle.attack_active);
let on = slot_view(attack, &following, None, None, true);
assert!(on.active, "the latch lights it even on a FOLLOW command");
assert!(on.attack_active, "and the next press calls the pet off");
// The latch never reaches another command's button, however busy the pet is.
let follow = slot_view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&following,
None,
None,
true,
);
assert!(!follow.attack_active);
assert!(follow.active, "…but Follow is still the standing command");
}
/// Reaction tokens use the `PET_MODE_*` keys — the state words — not the right-click menu's
/// `PET_AGGRESSIVE` imperatives. Identical in enUS, distinguishable only by key.
#[test]
fn reaction_tokens_use_the_mode_keys_and_light_on_the_current_react() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let def = slot_view(
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false,
);
assert_eq!(def.name.as_deref(), Some("PET_MODE_DEFENSIVE"));
assert_eq!(def.texture.as_deref(), Some("PET_DEFENSIVE_TEXTURE"));
assert!(def.is_token && def.active);
assert!(
!slot_view(
packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false
)
.active
);
}
/// A DISABLED bar (bit 27) changes what both token classes report, and neither is a special
/// case we wrote: the reaction compare's left side is forced to Passive, and the command
/// compare — unmasked `state >> 8` — is put out of range of every command at once.
#[test]
fn a_disabled_bar_reads_passive_and_lights_no_command() {
let mut bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
bar.state |= PET_STATE_BAR_DISABLED;
let passive = slot_view(
packed(PET_REACT_PASSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false,
);
assert!(passive.active, "a bar that cannot be ordered reads Passive");
assert!(
!slot_view(
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false
)
.active
);
assert!(
!slot_view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&bar,
None,
None,
false
)
.active,
"the command it IS on goes dark too"
);
}
/// `GetPetActionsUsable` — bit 27 OR the pet's own crowd-control flags. The second half is the
/// leg benilla was missing: a feared/stunned/confused pet's bar greys.
#[test]
fn usability_is_the_disabled_bit_and_the_pets_crowd_control() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
assert!(actions_usable(&bar, Some(0)));
assert!(
actions_usable(&bar, None),
"a missing descriptor is not a no"
);
for flag in [0x0004_0000, 0x0040_0000, 0x0080_0000] {
assert!(!actions_usable(&bar, Some(flag)), "flag {flag:#x} disables");
}
// POSSESSED is deliberately not one of them — that IS the pet-bar case.
assert!(actions_usable(&bar, Some(0x0100_0000)));
bar.spells.state |= PET_STATE_BAR_DISABLED;
assert!(!actions_usable(&bar, Some(0)));
}
/// A spell slot: real name, rank subtext, icon PATH — and the autocast pair read off **bits
/// 31/30**, not the type byte. `isActive` is nil on every spell path.
#[test]
fn spell_slots_read_their_autocast_off_bits_31_and_30() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let claw = spell("Claw", Some("Rank 3"));
let on = slot_view(
packed(3010, PET_ACT_ENABLED),
&bar,
Some(&claw),
None,
false,
);
assert_eq!(on.name.as_deref(), Some("Claw"));
assert_eq!(on.subtext.as_deref(), Some("Rank 3"));
assert_eq!(on.spell_id, Some(3010));
assert!(!on.is_token);
assert!(on.autocast_allowed && on.autocast_enabled);
assert!(!on.active, "a SPELL slot never reports isActive");
let off = slot_view(
packed(3010, PET_ACT_DISABLED),
&bar,
Some(&claw),
None,
false,
);
assert!(off.autocast_allowed && !off.autocast_enabled);
// A passive pet spell shows, but can never autocast — no ring, no sparkle.
let passive = slot_view(
packed(3010, PET_ACT_PASSIVE),
&bar,
Some(&claw),
None,
false,
);
assert!(!passive.autocast_allowed && !passive.autocast_enabled);
}
/// The two routes to an empty-looking button: the client's zero WORD, and vmangos's own
/// `(0, ACT_DISABLED)` filler, which takes the spell branch and misses the catalog. Both must
/// draw nothing — reading either one wrong puts four "?" buttons mid-bar.
#[test]
fn both_kinds_of_empty_slot_draw_nothing() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let zero = PetActionEntry::default();
assert!(zero.is_empty());
assert_eq!(
slot_view(zero, &bar, None, None, false),
PetActionView::default()
);
let filler = packed(0, PET_ACT_DISABLED);
assert!(!filler.is_empty(), "the WORD is not zero");
assert_eq!(
slot_view(filler, &bar, None, None, false),
PetActionView::default(),
"…but spell id 0 resolves to nothing, so the button is still empty"
);
}
/// A spell the catalog cannot name draws nothing rather than a nameless icon — and with it
/// goes the autocast pair, which the client also gates on the record resolving.
#[test]
fn an_unresolvable_spell_draws_nothing() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let v = slot_view(packed(999, PET_ACT_ENABLED), &bar, None, None, false);
assert_eq!(v, PetActionView::default());
assert!(!v.autocast_allowed && !v.autocast_enabled);
}
/// A type outside 1–7 is inert. The client's own default arm under-pushes here (wow-re §2.5);
/// we answer the empty slot instead, and in particular never reach the spell catalog with an
/// arbitrary number.
#[test]
fn an_unknown_type_byte_is_inert() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let claw = spell("Claw", None);
assert_eq!(
slot_view(packed(3010, 0x33), &bar, Some(&claw), None, false),
PetActionView::default()
);
}
/// The slot index map is 1-based and bounded — a stale index from the VM cannot read past the
/// ten words or wrap into slot 10.
#[test]
fn slot_lookup_is_one_based_and_bounded() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
bar.spells.bar[0] = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
bar.spells.bar[9] = packed(PET_REACT_PASSIVE, PET_ACT_REACTION);
assert_eq!(slot_entry(&bar, 1).unwrap().action(), PET_COMMAND_ATTACK);
assert_eq!(slot_entry(&bar, 10).unwrap().action(), PET_REACT_PASSIVE);
assert!(slot_entry(&bar, 0).is_none());
assert!(slot_entry(&bar, 11).is_none());
assert!(slot_entry_mut(&mut bar, 0).is_none());
assert!(slot_entry_mut(&mut bar, 11).is_none());
}
/// The local latch, which exists because the server confirms none of it (module doc), written
/// with the client's own masks: a command press moves bits 8–15 and keeps byte 0 **and bit
/// 27**, a reaction press moves byte 0 only, a spell press moves neither — and DISMISS moves
/// nothing, because it ends the pet rather than becoming its standing order.
#[test]
fn a_press_latches_the_state_the_server_never_echoes() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
bar.spells.state |= PET_STATE_BAR_DISABLED;
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND));
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert_eq!(bar.spells.react_state(), PET_REACT_DEFENSIVE, "untouched");
assert!(
bar.spells.bar_disabled(),
"the disabled bit is the SERVER's — a command press must preserve it"
);
latch_press(&mut bar, packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION));
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));
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));
assert_eq!(
bar.spells.command_state() & 0xFF,
PET_COMMAND_STAY,
"Dismiss ends the pet; it never becomes its standing command"
);
}
/// 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.
#[test]
fn an_attack_press_raises_the_latch_and_dismiss_does_not() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
assert!(!bar.attacking);
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND));
assert!(bar.attacking);
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));
assert!(!bar.attacking, "only ATTACK raises it");
}
}
@@ -594,6 +594,7 @@ pub(super) fn drive_script(
CursorPayload::Item(i) => i.texture,
CursorPayload::Spell(s) => s.texture,
CursorPayload::Action(a) => a.texture,
CursorPayload::Macro(m) => m.texture,
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
@@ -101,9 +101,10 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four entries with nothing behind them — AddOns, Edit Mode, Support, Macros — are DISABLED
/// (the pending idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry).
/// Everything else in the ladder is live.
/// The three entries with nothing behind them — AddOns, Edit Mode, Support — are DISABLED (the
/// pending idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry).
/// Everything else in the ladder is live. **Macros left this list in decision 0983**, when the
/// macro window landed behind it.
#[test]
fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
let s = harness();
@@ -113,7 +114,6 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
"GameMenuButtonAddOns",
"GameMenuButtonEditMode",
"GameMenuButtonSupport",
"GameMenuButtonMacros",
] {
assert!(
!s.eval::<bool>(&format!("return {name}:IsEnabled()"))
@@ -123,6 +123,7 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
}
for name in [
"GameMenuButtonOptions",
"GameMenuButtonMacros",
"GameMenuButtonLogout",
"GameMenuButtonQuit",
"GameMenuButtonContinue",
+330
View File
@@ -0,0 +1,330 @@
//! The shipped `assets/ui/MacroFrame.xml` — the macro editor + its name/icon popup (decision
//! 0983), driven against the engine's own macro table.
//!
//! What these guard, end to end through the real file: the window loads clean in its real
//! neighbourhood; NEW → pick an icon → OKAY actually creates a macro on the right tab; typing in
//! the body and closing the window commits it (the `MacroFrame_SaveMacro` line that makes the
//! whole editor work); the two tabs address the two index ranges; DELETE removes and re-selects;
//! and a macro button is a drag source that loads the cursor with the macro payload.
//!
//! The **saving** half is deliberately exercised through the window rather than the bindings: the
//! bindings' own round trip is `benilla_ui::script::macros`' unit tests, and what can only break
//! here is the wiring — a tab that forgets to save, an OKAY that creates on the wrong tab.
use benilla_ui::script::{CursorPayload, UiScript};
/// The window's real neighbourhood, in the manifest's own order.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The global strings the file reads by name (the app runs the real `GlobalStrings.lua`; here
// only the handful this window formats need to exist).
s.run(
r#"
CREATE_MACROS = "Create Macros"
GENERAL_MACROS = "General Macros"
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
ENTER_MACRO_LABEL = "Enter Macro Commands:"
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
DELETE = "Delete"
NEW = "New"
EXIT = "Exit"
CANCEL = "Cancel"
OKAY = "Okay"
MACROS = "Macros"
-- The tooltip plate colours the body's backdrop reads (the app gets these from
-- UIParent.lua's own globals; the window only needs them to exist).
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
"#,
)
.unwrap();
for file in [
"Fonts.xml",
"UiPanels.xml",
"ScrollTemplates.xml",
"MicroMenu.xml",
"MacroFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The icon chooser's list is the app's push; three entries is enough to index into.
s.set_macro_icons(vec![
"Interface\\Icons\\Ability_Ambush".into(),
"Interface\\Icons\\Ability_BackStab".into(),
"Interface\\Icons\\Spell_Fire_FlameBolt".into(),
]);
s
}
/// Assert no script error has been collected, naming the step that produced it.
fn no_errors(s: &UiScript, step: &str) {
assert!(s.errors().is_empty(), "{step}: {:?}", s.errors());
}
/// The whole creation flow through the window's own buttons: NEW opens the popup, an icon click
/// selects, OKAY enables and creates. This is the path a player takes, and every step of it is
/// wiring this file owns.
#[test]
fn new_then_pick_an_icon_then_okay_creates_the_macro() {
let s = harness();
s.run("ShowMacroFrame()").unwrap();
no_errors(&s, "show");
// OKAY starts disabled: no name, no icon.
s.run("BenillaMacroNewButton_OnClick()").unwrap();
assert!(s
.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled()")
.unwrap(),
"a nameless, iconless macro cannot be created"
);
s.run(r#"BenillaMacroPopupEditBox:SetText("Ambush")"#)
.unwrap();
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton1)")
.unwrap();
assert!(
s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled()")
.unwrap(),
"a name and an icon enable OKAY"
);
s.run("BenillaMacroPopupOkayButton_OnClick()").unwrap();
no_errors(&s, "okay");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 0),
"created on the ACCOUNT tab (macroBase 0)"
);
let (name, tex) = s
.eval::<(String, String)>("local n, t = GetMacroInfo(1) return n, t")
.unwrap();
assert_eq!(name, "Ambush");
assert_eq!(tex, "Interface\\Icons\\Ability_Ambush");
assert!(
!s.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
.unwrap(),
"OKAY closes the popup"
);
// …and the new macro is selected and shown in the detail pane.
assert_eq!(
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
.unwrap(),
"Ambush"
);
}
/// The body editor's commit path — the single line that makes the window an editor at all
/// (`MacroFrame_SaveMacro`, called from the tab switch, the list click, and the window's OnHide).
/// A regression here silently discards everything the player typed.
#[test]
fn typing_a_body_and_closing_the_window_commits_it() {
let s = harness();
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run(r#"BenillaMacroFrameText:SetText("/cast Ambush\n/say pew")"#)
.unwrap();
no_errors(&s, "type");
assert!(
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
.unwrap(),
"OnTextChanged marks the window dirty"
);
// The character counter is the ref's own MACROFRAME_CHAR_LIMIT fill.
assert_eq!(
s.eval::<String>("return BenillaMacroFrameCharLimitText:GetText()")
.unwrap(),
"21/255 Characters Used"
);
s.run(r#"HideUIPanel(BenillaMacroFrame)"#).unwrap();
no_errors(&s, "hide");
assert_eq!(
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
.unwrap(),
"/cast Ambush\n/say pew",
"closing the window commits the body"
);
}
/// The two tabs address the two index ranges, and switching tabs saves first. The 19 is the whole
/// point: `MacroFrame.macroBase` is 0 or MAX_MACROS, and every binding takes `macroBase + i`.
#[test]
fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
let s = harness();
s.run(r#"CreateMacro("Acct", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
// Type into the account macro, then switch tabs WITHOUT any explicit save.
s.run(r#"BenillaMacroFrameText:SetText("/say account")"#)
.unwrap();
s.run("BenillaMacroFrameTab2:Click()").unwrap();
no_errors(&s, "tab 2");
assert_eq!(
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
.unwrap(),
"/say account",
"the tab switch saved the body first"
);
assert_eq!(
s.eval::<i64>("return BenillaMacroFrame.macroBase").unwrap(),
18
);
// Creating on this tab lands at 19 (the character range's base + 1).
s.run("BenillaMacroNewButton_OnClick()").unwrap();
s.run(r#"BenillaMacroPopupEditBox:SetText("Char")"#)
.unwrap();
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton2)")
.unwrap();
s.run("BenillaMacroPopupOkayButton_OnClick()").unwrap();
no_errors(&s, "create on tab 2");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 1)
);
assert_eq!(
s.eval::<String>("return GetMacroInfo(19)").unwrap(),
"Char",
"the character tab's first slot is index 19"
);
// …and back to tab 1 shows the account macro again.
s.run("BenillaMacroFrameTab1:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaMacroFrame.macroBase").unwrap(),
0
);
assert_eq!(
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
.unwrap(),
"Acct"
);
}
/// DELETE removes the macro and leaves the window on a sane selection — the ref re-runs its own
/// OnLoad, which re-selects the first macro (or clears the detail pane when none is left).
#[test]
fn delete_removes_the_macro_and_re_selects() {
let s = harness();
s.run(r#"CreateMacro("One", 1, "/say one")"#).unwrap();
s.run(r#"CreateMacro("Two", 2, "/say two")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run("BenillaMacroDeleteButton:Click()").unwrap();
no_errors(&s, "delete");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 0)
);
// The list closed its gap, so slot 1 is now the survivor and it is what's selected.
assert_eq!(s.eval::<String>("return GetMacroInfo(1)").unwrap(), "Two");
assert_eq!(
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
.unwrap(),
"Two"
);
// Deleting the last one clears the detail pane rather than leaving a stale selection.
s.run("BenillaMacroDeleteButton:Click()").unwrap();
no_errors(&s, "delete last");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(0, 0)
);
assert!(
!s.eval::<bool>("return BenillaMacroFrameSelectedMacroButton:IsVisible()")
.unwrap(),
"no selection, no detail pane"
);
}
/// A macro button is a DRAG SOURCE: `OnDragStart` loads the cursor with the macro payload, which
/// `PlaceAction` then packs onto a bar slot under the MACRO tag. This is the only route a macro
/// reaches the action bar.
#[test]
fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
let s = harness();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run("BenillaMacroButton1:GetScript(\"OnDragStart\")(BenillaMacroButton1)")
.unwrap();
no_errors(&s, "drag");
let payload = s.cursor_payload();
assert!(
matches!(&payload, Some(CursorPayload::Macro(m)) if m.index == 1),
"the macro payload, carrying its index: {payload:?}"
);
assert_eq!(
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
.unwrap(),
("macro".to_string(), 1)
);
// Placing it on a bar slot packs the MACRO tag (0x40 << 24) with the macro index.
let mut s = s;
s.run("PlaceAction(1)").unwrap();
assert_eq!(s.take_action_sets(), vec![(1, 0x4000_0000 | 1)]);
}
/// The icon chooser's grid: 20 buttons over the app-pushed list, the tail hidden rather than
/// blank, and a click marking the selection.
#[test]
fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
let s = harness();
s.run("ShowMacroFrame()").unwrap();
s.run("BenillaMacroNewButton_OnClick()").unwrap();
no_errors(&s, "popup");
assert_eq!(s.eval::<i64>("return GetNumMacroIcons()").unwrap(), 3);
for i in 1..=3 {
assert!(
s.eval::<bool>(&format!("return BenillaMacroPopupButton{i}:IsVisible()"))
.unwrap(),
"button {i} shows an icon"
);
}
assert!(
!s.eval::<bool>("return BenillaMacroPopupButton4:IsVisible()")
.unwrap(),
"past the end of the list the button hides — not a blank square"
);
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton3)")
.unwrap();
assert_eq!(
s.eval::<i64>("return BenillaMacroPopupFrame.selectedIcon")
.unwrap(),
3
);
assert!(
s.eval::<bool>("return BenillaMacroPopupButton3:GetChecked()")
.unwrap(),
"the picked icon is checked"
);
}
+27 -26
View File
@@ -202,9 +202,7 @@ impl Plugin for UiScriptPlugin {
arbitrate_pointer_over_ui
.after(UiInput)
.before(WorldStage::Input),
)
// Runtime era-atlas misses surface as WARNs the frame they happen (decision 0950).
.add_systems(Update, warn_era_atlas_misses.after(UiInput));
);
// `WOW_CAPTURE_UI=1` on a capture: override the "player" token with a synthetic snapshot so
// the unit frames are populated in a server-less capture. Ordered after the real feed (so it
@@ -240,7 +238,7 @@ fn arbitrate_pointer_over_ui(
}
fn setup_script(world: &mut World) {
let mut script = match UiScript::new() {
let script = match UiScript::new() {
Ok(s) => s,
Err(e) => {
error!("ui_script: VM init failed: {e}");
@@ -249,12 +247,6 @@ fn setup_script(world: &mut World) {
};
load_global_strings(world, &script);
load_emote_tokens(world, &script);
// The Era atlas table (decision 0950) — pushed BEFORE the XML loads so `atlas=` attributes
// resolve during materialization. A missing manifest already WARNed inside the loader; the
// windows then draw blank and every unresolved name is drained as a miss below.
if let Some(entries) = crate::assets::era::load_era_atlases() {
script.set_era_atlases(entries);
}
// The unit frames are the default player UI — always loaded in a normal run. Captures stay
// pristine (world-render baselines) unless WOW_CAPTURE_UI=1 opts the UI in.
let capturing = world.contains_resource::<crate::capture::CaptureMode>();
@@ -263,23 +255,9 @@ fn setup_script(world: &mut World) {
// assertion (`shipped_xml_tests`), not a second reporting channel.
let _ = load_default_ui(&script);
}
// Boot-time atlas misses (an `atlas=` or OnLoad `SetAtlas` the table couldn't serve) name
// themselves once, here; runtime misses drain per frame in `warn_era_atlas_misses`.
for name in script.take_era_atlas_misses() {
warn!("ui_script: unresolved era atlas '{name}' — stale extraction? run scripts/era-extract.py");
}
world.insert_non_send_resource(script);
}
/// Per-frame drain of runtime `SetAtlas` misses (hover/selection swaps naming an atlas the boot
/// table lacks) — warn-once per name, the boot drain's live twin. Free when nothing missed.
fn warn_era_atlas_misses(script: Option<NonSendMut<UiScript>>) {
let Some(mut script) = script else { return };
for name in script.take_era_atlas_misses() {
warn!("ui_script: unresolved era atlas '{name}' — stale extraction? run scripts/era-extract.py");
}
}
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
@@ -474,6 +452,13 @@ fn load_default_ui(script: &UiScript) -> Vec<String> {
// (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
// anchor target).
"StanceBar.xml",
// The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
// i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
// never has one never sees it. Right after the stance bar, whose row it shares (either
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
// GameTooltip.xml (its hover).
"PetActionBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
// into BenillaActionBarArtFrame by name and seats itself with that file's
@@ -621,8 +606,18 @@ fn load_default_ui(script: &UiScript) -> Vec<String> {
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
"ChatFrame.xml",
// The options window (decision 0950): the era-skinned OptionsFrame the game menu's
// Options button opens. Needs only Fonts' objects and UiPanels' panel manager (both far
// The macro window (decision 0983): the editor + its name/icon popup, driven over the
// engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
// side, so this is the one window whose model is not an app feed). Needs UiPanels (the
// panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
// the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
"MacroFrame.xml",
// The options window (the 0985 provenance split: 0950's era structure, 0978's
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
// cuts — bare fully-live sliders, no corner X): the era-shaped
// OptionsFrame the game menu's Options button opens. Needs only Fonts' objects and UiPanels' panel manager (both far
// above); sits right before GameMenuFrame so the menu — which reaches it by name at
// click time — stays the last, outermost layer.
"OptionsFrame.xml",
@@ -883,6 +878,9 @@ mod action_bar_tests;
#[cfg(test)]
mod multibar_stance_tests;
#[cfg(test)]
mod pet_bar_tests;
#[cfg(test)]
mod micro_menu_tests;
@@ -930,6 +928,9 @@ mod escape_tests;
#[cfg(test)]
mod game_menu_tests;
#[cfg(test)]
mod macro_tests;
#[cfg(test)]
mod options_tests;
+351 -75
View File
@@ -1,14 +1,17 @@
//! The shipped `assets/ui/OptionsFrame.xml` — the era-skinned options window (decision 0950:
//! the shell; 0957: the Audio page; 0959: the Graphics page).
//! The shipped `assets/ui/OptionsFrame.xml` — the era-shaped, 1.12-skinned options window
//! (0950: the shell; 0957: the Audio page; 0959: the Graphics page; 0978: the 1.12-native
//! skin — no era extraction, every texture from the MPQ chain; 0981: the 1.14 System-window
//! dialog chrome — translucent dark ground, outline boxes, hairline dividers; 0984: the 1.14
//! select/hover wash mechanism, the working era search; 0985: the provenance split those
//! cite; 0989: the directed cuts — steppers and the corner X gone, the whole bar live via
//! the engine's track-press law, the search box at the era's verbatim seat).
//!
//! What these guard: the file loads clean inside the real neighbourhood (Fonts + UiPanels +
//! GameMenuFrame); the menu's Options button is the door in (menu down, options up, on the ref's
//! own kit); Controls is the default category and the page title follows the selection; both
//! close spellings put the window away; and — against a hand-pushed atlas table, the
//! era_atlas_tests idiom — the selected row actually wears `options_list_active` and drops it
//! when the selection moves. The harness pushes NO atlas table by default: `SetAtlas` is a
//! warn-once no-op there by design (a missing extraction must not kill the UI), which is exactly
//! why the one atlas-backed test pushes its own two members.
//! close spellings put the window away; the selected row wears the LOCKED GOLD additive wash
//! and hover runs the steel-blue one (the 1.14 pair); and the search reflows the live rows
//! under category heads and restores the authored page exactly.
//!
//! The page tests (0957 Audio, 0959 Graphics) run against the REAL registered CVar set
//! (`crate::cvars`): rows read the table on select, writes land on the change queue the host
@@ -16,7 +19,7 @@
//! farclip min-anchored 60), the 1.12 master→ambience dependency greys, and Defaults walks the
//! visible page back to the registered defaults.
use benilla_ui::script::{EraAtlasEntry, SoundRequest, UiScript};
use benilla_ui::script::{QuadContent, SoundRequest, UiScript};
/// The window's real neighbourhood, in the manifest's own order (options before the menu — the
/// game_menu_tests::harness_with idiom, minus the extras this file never needs).
@@ -24,7 +27,7 @@ fn harness() -> UiScript {
harness_on(UiScript::new().unwrap())
}
/// Load the four files onto a prepared script — split out so the atlas test can push its table
/// Load the four files onto a prepared script — split out so page tests can seed CVars
/// BEFORE the XML loads, the way the app does (ui_script::setup_script).
fn harness_on(mut s: UiScript) -> UiScript {
s.set_screen_size(1024.0, 768.0);
@@ -147,11 +150,11 @@ fn clicking_a_row_moves_the_selection_and_the_page_title() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Both close spellings — the red Close and the corner X — hide the window on the shared
/// igMainMenuClose kit; a page WITHOUT rows keeps Defaults disabled (Controls has rows since
/// 0961, so the rowless check moved to Interface).
/// The red Close hides the window on the igMainMenuClose kit, the corner X does not EXIST
/// (0989's directed cut), and a page WITHOUT rows keeps Defaults disabled (Controls has rows
/// since 0961, so the rowless check moved to Interface).
#[test]
fn both_close_buttons_hide_the_window_and_defaults_is_disabled() {
fn the_close_button_hides_the_window_and_defaults_is_disabled() {
let mut s = harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
@@ -169,70 +172,357 @@ fn both_close_buttons_hide_the_window_and_defaults_is_disabled() {
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuClose".into())));
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let _ = s.take_sounds();
s.run("OptionsFrameClosePanelButton:Click()").unwrap();
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
// No corner X (0989's directed cut — the era HAS one; the red button and ESC are the
// window's exits).
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuClose".into())));
.eval::<bool>("return OptionsFrameClosePanelButton == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The row art, against a hand-pushed two-member atlas table (real manifest numbers, the
/// era_atlas_tests idiom): the selected row's bg wears `options_list_active` at atlas size, and
/// a moved selection strips the old row bare (`SetTexture(nil)` → GetAtlas nil). The window's
/// OTHER atlas members (nine-slice, divider, inner panel) stay unserved here on purpose — they
/// drain as warn-once misses, never errors.
/// The row wash (0984: the 1.14 OptionsListButtonTemplate mechanism, verbatim — ONE
/// UI-QuestLogTitleHighlight quad in ADD blend, two tints): the selected row's wash draws
/// additive in the LOCKED GOLD (1,1,0) at the era plate seat (187x21 — sized once in
/// OptionsCategoryRow_OnLoad); a moved selection reseats the single gold quad lower; and
/// hovering an UNSELECTED row lights the same texture in the steel-blue hover tint
/// (.196,.388,.8) while the locked gold stands — 1.14's LockHighlight guard.
#[test]
fn the_selected_row_wears_the_active_atlas_and_yields_it_on_reselect() {
let mut s = UiScript::new().unwrap();
s.set_era_atlases([
(
"options_list_active".to_string(),
EraAtlasEntry {
file: "era:textures/1318750.blp".to_string(),
uv: [604.0 / 1024.0, 791.0 / 1024.0, 1.0 / 1024.0, 22.0 / 1024.0],
size: [187.0, 21.0],
},
),
(
"options_list_hover".to_string(),
EraAtlasEntry {
file: "era:textures/1318750.blp".to_string(),
uv: [793.0 / 1024.0, 980.0 / 1024.0, 1.0 / 1024.0, 22.0 / 1024.0],
size: [187.0, 21.0],
},
),
]);
let s = harness_on(s);
fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
let mut s = harness();
// Pin the window at scale 1 so row rects and the pointer share coordinates (the fit
// clamp stays out of the way at 1024x768: both ratios sit above 1).
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
assert_eq!(
s.eval::<String>("return OptionsFrameCategoryListRowControlsBg:GetAtlas()")
.unwrap(),
"options_list_active",
"the default selection wears the active art"
);
// useAtlasSize rode along: the bg is the member's nominal 187×21, wider than the 175 row —
// the era's own look.
// The plate seat: the era's 187x21, authored in the row OnLoad now that no
// SetAtlas(name, true) sizes it.
assert_eq!(
s.eval::<f64>("return OptionsFrameCategoryListRowControlsBg:GetWidth()")
.unwrap(),
187.0
);
assert_eq!(
s.eval::<f64>("return OptionsFrameCategoryListRowControlsBg:GetHeight()")
.unwrap(),
21.0
);
// Every visible category wash: rect + tint + blend.
let washes = |s: &mut UiScript| -> Vec<(benilla_ui::layout::Rect, [f32; 4], bool)> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
additive,
..
} if p.contains("UI-QuestLogTitleHighlight") => {
q.rect.map(|r| (r, color.unwrap_or([1.0; 4]), *additive))
}
_ => None,
})
.collect()
};
let gold =
|c: &[f32; 4]| (c[0] - 1.0).abs() < 1e-3 && (c[1] - 1.0).abs() < 1e-3 && c[2].abs() < 1e-3;
let blue = |c: &[f32; 4]| {
(c[0] - 0.196).abs() < 1e-3 && (c[1] - 0.388).abs() < 1e-3 && (c[2] - 0.8).abs() < 1e-3
};
let before = washes(&mut s);
assert_eq!(
before.len(),
1,
"exactly one wash shows: the locked selection"
);
assert!(before[0].2, "the wash draws ADD — 1.14's alphaMode");
assert!(gold(&before[0].1), "…in the locked gold: {:?}", before[0].1);
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
let after = washes(&mut s);
assert_eq!(after.len(), 1, "the wash moved, not multiplied");
assert!(gold(&after[0].1), "still the gold tint: {:?}", after[0].1);
assert!(
s.eval::<bool>("return OptionsFrameCategoryListRowControlsBg:GetAtlas() == nil")
after[0].0.top < before[0].0.top,
"Audio sits below Controls, so the wash reseats lower (top {} -> {})",
before[0].0.top,
after[0].0.top
);
// Hover the now-unselected Controls row: its wash lights BLUE beside Audio's gold.
let (cx, cy) = {
let l: f32 = s
.eval("return OptionsFrameCategoryListRowControls:GetLeft()")
.unwrap();
let r: f32 = s
.eval("return OptionsFrameCategoryListRowControls:GetRight()")
.unwrap();
let t: f32 = s
.eval("return OptionsFrameCategoryListRowControls:GetTop()")
.unwrap();
let b: f32 = s
.eval("return OptionsFrameCategoryListRowControls:GetBottom()")
.unwrap();
((l + r) * 0.5, (t + b) * 0.5)
};
s.mouse_move(cx, cy);
let hovered = washes(&mut s);
assert_eq!(
hovered.len(),
2,
"hover adds its wash beside the locked gold"
);
assert_eq!(hovered.iter().filter(|w| gold(&w.1)).count(), 1);
assert_eq!(
hovered.iter().filter(|w| blue(&w.1)).count(),
1,
"the hover tint is 1.14's steel-blue: {:?}",
hovered.iter().map(|w| w.1).collect::<Vec<_>>()
);
// Leaving puts the hover wash away; the lock stays.
s.mouse_move(5.0, 5.0);
let left = washes(&mut s);
assert_eq!(left.len(), 1);
assert!(gold(&left[0].1));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Search (0984: the era SettingsPanel mechanism transcribed): typing reflows the LIVE
/// matching rows under a clickable category head — a matched CHILD pulls its parent volume
/// row in above it (the era parentInitializer rule) — the title reads "Search Results" and
/// Defaults hides; clearing the box lands every row back on its authored XML chain exactly
/// (the one-layout-law pin) with the page view restored.
#[test]
fn search_reflows_live_rows_and_restores_the_page() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
s.resolve();
let authored_top: f32 = s
.eval("return OptionsFrameContainerBodyAudioRowMaster:GetTop()")
.unwrap();
s.run("OptionsFrameSearchBox:SetText(\"music\")").unwrap();
assert_eq!(
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
.unwrap(),
"the old row stripped bare when the selection moved"
"Search Results"
);
assert!(
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsVisible()")
.unwrap(),
"Defaults hides while the search holds the page (the era SetShown(not hasText))"
);
// One head — Audio's; the matches plus their pulled-in parent; nothing else.
for (frame, shown) in [
("OptionsFrameContainerBodySearchHeadAudio", true),
("OptionsFrameContainerBodySearchHeadControls", false),
("OptionsFrameContainerBodySearchHeadGraphics", false),
("OptionsFrameContainerBodyAudioRowMusic", true),
("OptionsFrameContainerBodyAudioRowEnableMusic", true),
("OptionsFrameContainerBodyAudioRowMaster", true), // the parent, pulled in unmatched
("OptionsFrameContainerBodyAudioRowSound", false),
("OptionsFrameContainerBodyAudioRowEnableAll", false),
("OptionsFrameContainerBodyControlsRowAutoLoot", false),
("OptionsFrameContainerBodyGraphicsRowUiScale", false),
("OptionsFrameContainerBodyNoResults", false),
] {
assert_eq!(
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap(),
shown,
"{frame} shown={shown}"
);
}
// The chain reads head → parent → the two matches, downward.
s.resolve();
let tops: Vec<f32> = [
"OptionsFrameContainerBodySearchHeadAudio",
"OptionsFrameContainerBodyAudioRowMaster",
"OptionsFrameContainerBodyAudioRowMusic",
"OptionsFrameContainerBodyAudioRowEnableMusic",
]
.iter()
.map(|f| s.eval::<f32>(&format!("return {f}:GetTop()")).unwrap())
.collect();
assert!(
tops[0] > tops[1] && tops[1] > tops[2] && tops[2] > tops[3],
"head, parent, then the matches chain downward: {tops:?}"
);
// The reflowed row is LIVE: moving the music slider writes its CVar mid-search.
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerBodyAudioRowMusicControlSlider:SetValue(0.25)")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("MusicVolume".to_string(), "0.25".to_string())]
);
// Clearing restores the page view and the authored chain EXACTLY.
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
assert_eq!(
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
.unwrap(),
"Audio"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsVisible()")
.unwrap());
s.resolve();
let restored_top: f32 = s
.eval("return OptionsFrameContainerBodyAudioRowMaster:GetTop()")
.unwrap();
assert!(
(restored_top - authored_top).abs() < 0.01,
"the restore law equals the authored XML chain ({restored_top} vs {authored_top})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The era's word scoring (Blizzard_Settings.lua MatchesSearchTags + the words list): the
/// WHOLE query is the first word, so a phrase match outscores its own tokens and chains
/// first within the group — "master volume" seats Master above the token-matched children.
#[test]
fn the_phrase_match_outranks_its_words() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameSearchBox:SetText(\"master volume\")")
.unwrap();
s.resolve();
let tops: Vec<f32> = [
"OptionsFrameContainerBodyAudioRowMaster", // phrase hit, score 12
"OptionsFrameContainerBodyAudioRowSound", // "VOLUME" hits, score 5, page order
"OptionsFrameContainerBodyAudioRowMusic",
"OptionsFrameContainerBodyAudioRowAmbience",
]
.iter()
.map(|f| s.eval::<f32>(&format!("return {f}:GetTop()")).unwrap())
.collect();
assert!(
tops[0] > tops[1] && tops[1] > tops[2] && tops[2] > tops[3],
"phrase first, then the word matches in page order: {tops:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The era's search exits: clicking a results head (the era's jump — SelectCategory clears
/// the box) and a no-match query (the section-header "no results" line, nothing chained).
#[test]
fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
let s = harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameSearchBox:SetText(\"volume\")").unwrap();
s.run("OptionsFrameContainerBodySearchHeadAudio:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return OptionsFrameSearchBox:GetText()")
.unwrap(),
"",
"the head click cleared the search (the era SelectCategory law)"
);
assert_eq!(
s.eval::<String>("return OptionsFrameCategoryListRowAudioBg:GetAtlas()")
s.eval::<String>("return OptionsFrame.selectedCategory")
.unwrap(),
"options_list_active"
"Audio"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
.unwrap(),
"Audio"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
s.run("OptionsFrameSearchBox:SetText(\"flibbertigibbet\")")
.unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
for head in ["Controls", "Audio", "Graphics"] {
assert!(
!s.eval::<bool>(&format!(
"return OptionsFrameContainerBodySearchHead{head}:IsVisible()"
))
.unwrap(),
"no head on a miss"
);
}
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar IS the control (0989: steppers cut by the director's call; the engine's
/// track-press law): a LeftButton press on the slider's track — off the thumb — seats the
/// thumb under the cursor (the value jumps, the CVar write queues), the SAME press keeps
/// dragging (0250 §5's capture began), and the stepper buttons no longer exist.
#[test]
fn a_track_press_seats_the_thumb_and_keeps_dragging() {
let mut s = harness_on(audio_harness());
s.set_cvar_host("MasterVolume", "0.1");
s.run("ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
let _ = s.take_cvar_changes();
// The steppers are gone.
for btn in ["Back", "Forward"] {
assert!(s
.eval::<bool>(&format!(
"return OptionsFrameContainerBodyAudioRowMasterControl{btn} == nil"
))
.unwrap());
}
let slider = "OptionsFrameContainerBodyAudioRowMasterControlSlider";
s.resolve(); // seat the rects before reading them (the wash test's idiom)
let (l, r, t, b) = (
s.eval::<f32>(&format!("return {slider}:GetLeft()"))
.unwrap(),
s.eval::<f32>(&format!("return {slider}:GetRight()"))
.unwrap(),
s.eval::<f32>(&format!("return {slider}:GetTop()")).unwrap(),
s.eval::<f32>(&format!("return {slider}:GetBottom()"))
.unwrap(),
);
let cy = (t + b) * 0.5;
// Press 1 unit inside the RIGHT end of the track — far from the 0.1-seated thumb. The
// center-grab seat clamps the fraction to 1.0 there (the cursor sits inside the thumb's
// half-width end zone), so the value lands exactly at the max.
s.mouse_button(r - 1.0, cy, "LeftButton", true);
assert!(
s.eval::<bool>(&format!(
"return math.abs({slider}:GetValue() - 1.0) < 0.0001"
))
.unwrap(),
"the press itself seats the thumb"
);
assert_eq!(
s.take_cvar_changes(),
vec![("MasterVolume".to_string(), "1".to_string())]
);
// Still held: the same gesture drags on. The slider's midpoint is exactly fraction 0.5
// ((mid − thumb/2 − left) / (width − thumb)), on the 0.05 grid, so no snap correction.
s.mouse_move((l + r) * 0.5, cy);
assert!(
s.eval::<bool>(&format!(
"return math.abs({slider}:GetValue() - 0.5) < 0.0001"
))
.unwrap(),
"the capture began at the press — the drag follows without re-grabbing"
);
assert_eq!(
s.take_cvar_changes(),
vec![("MasterVolume".to_string(), "0.5".to_string())]
);
s.mouse_button((l + r) * 0.5, cy, "LeftButton", false);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -324,17 +614,6 @@ fn a_slider_move_snaps_and_writes_the_cvar() {
vec![("MasterVolume".to_string(), "0.45".to_string())]
);
// The steppers move one step on the era's own kit sound.
let _ = s.take_sounds();
s.run("OptionsFrameContainerBodyAudioRowMasterControlForward:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("MasterVolume".to_string(), "0.5".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -504,14 +783,11 @@ fn the_ui_scale_slider_defers_to_the_apply_button() {
.eval::<bool>("return OptionsFrameApplyButton:IsEnabled()")
.unwrap());
// The stepper still moves the slider (and plays its kit) — still nothing queues.
let _ = s.take_sounds();
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlForward:Click()")
// A second move re-stages the pending value — still nothing queues (0989: the steppers
// are gone; a drag's SetValue is the remaining move).
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.8)")
.unwrap();
assert!(s.take_cvar_changes().is_empty());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Apply commits the LAST pending value, once, and the button goes away.
s.run("OptionsFrameApplyButton:Click()").unwrap();
@@ -0,0 +1,362 @@
//! The pet action bar (`PetActionBar.xml`) driven end to end through the REAL shipped XML
//! (decision 0982) — the `multibar_stance_tests` pattern: a self-contained loader, then the
//! whole chain from a pushed slot list to the quads it actually paints.
use benilla_ui::script::{PetActionView, QuadContent, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors in {file}: {:?}",
report.errors
);
}
/// The pet bar's own load prerequisites, in manifest order: UiPanels (`SetDesaturation`, the
/// disabled-bar grey), UIParent (the managed bottom stack its OnShow/OnHide re-fires), Cooldown
/// (`CooldownFrame_SetTimer`), ActionBar (the `BenillaActionBar` anchor target) — then the bar.
fn load_pet_bar(s: &UiScript) {
for file in [
"UiPanels.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
] {
load_xml(s, file);
}
}
/// GlobalStrings is loaded from the MPQ at runtime, not by the loader — a token slot's `name` is
/// a KEY into it, so the tests declare the two keys they read. (That the real keys exist in the
/// shipped file is a separate fact, asserted in `ui_pet`'s own tests by name.)
fn declare_token_strings(s: &UiScript) {
s.run(
"PET_ACTION_ATTACK = 'Attack' \
PET_MODE_DEFENSIVE = 'Defensive'",
)
.unwrap();
}
/// A hunter's bar as the server actually sends it: Attack (a lit command token), an empty spell
/// slot, Claw (a spell with autocast running), and Defensive (a lit reaction token).
fn hunter_slots() -> Vec<PetActionView> {
let mut slots = vec![PetActionView::default(); 10];
slots[0] = PetActionView {
name: Some("PET_ACTION_ATTACK".into()),
texture: Some("PET_ATTACK_TEXTURE".into()),
is_token: true,
active: true,
attack_active: true,
..Default::default()
};
slots[3] = PetActionView {
name: Some("Claw".into()),
subtext: Some("Rank 3".into()),
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
spell_id: Some(3010),
autocast_allowed: true,
autocast_enabled: true,
..Default::default()
};
slots[8] = PetActionView {
name: Some("PET_MODE_DEFENSIVE".into()),
texture: Some("PET_DEFENSIVE_TEXTURE".into()),
is_token: true,
active: true,
..Default::default()
};
slots
}
/// Count `path` quads **in the pet bar's own row**. The prerequisite `ActionBar.xml` brings 12
/// action buttons wearing the same `UI-Quickslot2` ring, so an unscoped count would be measuring
/// the main bar. Cut by the quad's CENTRE, not an edge: the button rings overhang their buttons by
/// a dozen pixels each way (54 px of ring on a 30 px button), so the two rows' extents actually
/// overlap even though their centres — 21 for the main bar, 70..75 for the pet bar — do not.
fn textures(quads: &[benilla_ui::script::ExtractedQuad], path: &str) -> usize {
quads
.iter()
.filter(|q| q.rect.is_none_or(|r| (r.top + r.bottom) / 2.0 >= 50.0))
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.count()
}
fn texture_rect(
quads: &[benilla_ui::script::ExtractedQuad],
path: &str,
) -> Option<benilla_ui::layout::Rect> {
quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.and_then(|q| q.rect)
}
/// The whole bar, end to end: hidden with no pet, shown with one, the three slot classes each
/// painting what they should, and hidden again when the pet goes.
#[test]
fn the_shipped_pet_bar_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
// No pet: the bar is hidden, and nothing of it reaches the quad pass.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
assert_eq!(
textures(&s.extract(), "Interface\\PetActionBar\\UI-PetBar"),
0,
"no pet, no shelf"
);
// A pet appears. The tick is what runs the bar's OnUpdate, i.e. the sparkle trail — it is an
// animation, so it exists from the first frame after the repaint, not from the repaint.
s.set_pet_actions(true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.tick(0.05);
s.resolve();
let quads = s.extract();
// The shelf art: both UI-PetBar strips draw.
assert_eq!(
textures(&quads, "Interface\\PetActionBar\\UI-PetBar"),
2,
"the two shelf strips"
);
// A TOKEN slot resolved its texture through the global the app named — the `getglobal` fork.
// Seeing the ICON PATH here (not the literal "PET_ATTACK_TEXTURE") is the whole proof.
assert_eq!(
textures(&quads, "Interface\\Icons\\Ability_GhoulFrenzy"),
1,
"Attack's icon came from PET_ATTACK_TEXTURE"
);
assert_eq!(
textures(&quads, "Interface\\Icons\\Ability_Defend"),
1,
"Defensive's icon came from PET_DEFENSIVE_TEXTURE"
);
// A SPELL slot took its path verbatim.
assert_eq!(textures(&quads, "Interface\\Icons\\Ability_Druid_Rake"), 1);
// Occupancy: 3 named slots show their filled ring, the 7 unnamed ones are hidden outright
// (the reference hides an unnamed button rather than showing an empty well).
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-Quickslot2"),
3,
"one filled ring per occupied slot"
);
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-Quickslot"),
0,
"an unnamed pet slot hides; it does not draw the empty ring"
);
// The checked ring is on both lit slots, and nowhere else.
assert_eq!(
textures(&quads, "Interface\\Buttons\\CheckButtonHilight"),
2,
"Attack + Defensive are lit; Claw is not"
);
// Autocast: the static ring on the one slot that allows it, and the sparkle trail running.
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
1,
"only Claw can autocast"
);
assert_eq!(
textures(&quads, "Interface\\Buttons\\GlowStar"),
32,
"4 emitters x 8 trail stars, on the one autocasting slot"
);
// Geometry, quoted from the ref: the bar's TOPLEFT is BenillaActionBar's BOTTOMLEFT +(36,97),
// and BenillaActionBar is the 1024x53 frame at screen BOTTOM ⇒ its BOTTOMLEFT is (0,0), so the
// frame spans y[54,97]. Button 1 sits at the frame's own BOTTOMLEFT +(36,2) ⇒ x[72,102]
// y[56,86] — the same 72 the reference lands on (its PETACTIONBAR_XPOS 36 + the button's 36).
let attack =
texture_rect(&quads, "Interface\\Icons\\Ability_GhoulFrenzy").expect("Attack icon");
assert_eq!(
(attack.left, attack.bottom, attack.right, attack.top),
(72.0, 56.0, 102.0, 86.0)
);
// The pet goes: the bar hides again, sparkles and all.
s.set_pet_actions(false, true, Vec::new());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.tick(0.05);
s.resolve();
let gone = s.extract();
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
assert_eq!(textures(&gone, "Interface\\Buttons\\GlowStar"), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The click law: left runs the slot, right flips autocast, and a left click on the ATTACK slot
/// while the pet is already attacking calls it off instead of re-ordering it (the reference's
/// `IsPetAttackActive` fork — the one branch that makes the Attack button a toggle).
#[test]
fn clicks_route_through_the_attack_toggle_fork() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
// Button 1 (Attack) spans x[72,102] y[56,86] ⇒ centre (87,71). Claw is slot 4: the buttons
// chain +8 on a 30 px width, so button 4's left = 72 + 3*38 = 186 ⇒ centre (201,71).
let click = |s: &mut UiScript, x: f32, y: f32, button: &str| {
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
};
// The pet IS attacking, so a left click on Attack calls it off — no action is queued.
click(&mut s, 87.0, 71.0, "LeftButton");
assert_eq!(s.take_pet_stop_attacks(), 1);
assert!(
s.take_pet_actions().is_empty(),
"the call-off replaces the press, it does not accompany it"
);
// Not attacking any more: the same click orders the attack.
let mut slots = hunter_slots();
slots[0].active = false;
slots[0].attack_active = false;
s.set_pet_actions(true, true, slots);
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
click(&mut s, 87.0, 71.0, "LeftButton");
assert_eq!(s.take_pet_actions(), vec![1]);
assert_eq!(s.take_pet_stop_attacks(), 0);
// Right-clicking the spell slot flips its autocast; right-clicking a command token does not
// (a token has no spell id for the wire verb to name).
click(&mut s, 201.0, 71.0, "RightButton");
assert_eq!(s.take_pet_autocast_toggles(), vec![4]);
click(&mut s, 87.0, 71.0, "RightButton");
assert!(
s.take_pet_autocast_toggles().is_empty(),
"a command token cannot autocast"
);
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.
#[test]
fn a_disabled_bar_greys_rather_than_hides() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, false, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
let quads = s.extract();
assert_eq!(
textures(&quads, "Interface\\PetActionBar\\UI-PetBar"),
2,
"the bar is still on screen"
);
// `SetDesaturation` has no shader path in this engine, so the grey is the reference's own
// no-shader fallback: vertex colour 0.5 (UiPanels.xml's transcription).
let tint = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*color),
_ => None,
})
.expect("Claw's icon");
assert_eq!(
tint.map(|c| [c[0], c[1], c[2]]),
Some([0.5, 0.5, 0.5]),
"a disabled bar greys every icon on it"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pet bar as it lands with the bottom-left multibar loaded and shown — which is how benilla
/// actually runs (0270: the bottom bars are always on). `shelf` counts the two `UI-PetBar` strips;
/// `attack_top` is the top edge of Attack's icon, the bar's own row in one number.
fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"UiPanels.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
] {
load_xml(&s, file);
}
if with_multibar {
load_xml(&s, "MultiBars.xml");
}
load_xml(&s, "PetActionBar.xml");
declare_token_strings(&s);
s.set_pet_actions(true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.tick(0.05);
s.resolve();
let quads = s.extract();
let shelf = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p == "Interface\\PetActionBar\\UI-PetBar")
})
.count();
let attack_top = texture_rect(&quads, "Interface\\Icons\\Ability_GhoulFrenzy")
.expect("Attack's icon draws")
.top;
(shelf, attack_top)
}
/// **The row the pet bar shares with the bottom-left multibar** (decision 0988, director-caught:
/// the bar drew straight across a live row of spells, border and all).
///
/// Two rules, both the reference's, both applied by `UIParent_ManageFramePositions`: with that bar
/// up the pet bar rises by the ref's 43 px, and its shelf art — which is a border only while the
/// bar sits directly on the main bar — is hidden rather than drawn over the buttons below.
///
/// The regression this locks is specifically that the manage pass RUNS: the bar's own XML anchor
/// is the base position, so a pass that never fired would leave it exactly where the bug was.
#[test]
fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
let (low_shelf, low_top) = pet_bar_row(false);
let (high_shelf, high_top) = pet_bar_row(true);
assert_eq!(
low_shelf, 2,
"on the main bar, the shelf IS the bar's border"
);
assert_eq!(high_shelf, 0, "raised, it would draw across the row below");
let risen = low_top - high_top;
assert!(
(risen.abs() - 43.0).abs() < 0.5,
"the bar must rise by the ref's 43 px (moved {risen})"
);
}
+54 -5
View File
@@ -199,9 +199,33 @@ pub(crate) fn line_advances(atlas: &mut UiFontAtlas, text: &str, font: FontSpec)
cum[i] = cum[i - 1];
}
}
// Into DRAWN space ([`drawn_k`]): the caret/click/selection geometry these feed must land on
// the glyphs the 0581 rescale actually put on screen, not on the pre-rescale shaping.
let k = drawn_k(font.height, font_size);
if k != 1.0 {
for v in &mut cum {
*v *= k;
}
}
cum
}
/// The exact-height rescale factor the DRAW applies (decision 0581, [`layout_text_quads_inner`]'s
/// tail): a requested height between baked sizes shapes at the snapped `font_size` and scales the
/// finished quads by `req / font_size` about the justify anchor. Every measure that hands geometry
/// back onto the drawn glyphs — the editbox advance table, the caret cell, the row pitch — must
/// return values in that DRAWN space, so they apply the same factor. Identity for a baked size and
/// for `height: None` (the draw's rescale gate is `font.height`-Some too). This stopped being a
/// theoretical case when the era-shaped windows started riding `SetScale` (0950): their frame
/// scale multiplies into every drawn height, landing between bakes — the options search box's
/// caret sat 28% past its own text (director, 2026-08-05; decision 0989).
fn drawn_k(font_height: Option<f32>, font_size: f32) -> f32 {
match font_height {
Some(req) if ((req / font_size) - 1.0).abs() > 1e-3 => req / font_size,
_ => 1.0,
}
}
/// The multiline-EditBox row answer (the 2-D half of
/// [`benilla_ui::script::UiScript::set_editbox_advances`]'s payload): the byte offset where each
/// wrapped display row of `text` begins at `wrap_width` — the exact [`wrap_line`] pass the render
@@ -256,7 +280,10 @@ pub(crate) fn line_rows(
if rows.is_empty() {
rows.push(0);
}
(rows, font_size)
// Row PITCH in drawn space ([`drawn_k`] — vertical distances rescale with the quads). The row
// STARTS stay: both this wrap and the draw's break at unscaled steps against the same width,
// so the byte boundaries already agree.
(rows, font_size * drawn_k(font.height, font_size))
}
/// [`line_rows`]'s reconstruction walk for one `\n` segment: push the byte start of each wrapped
@@ -588,11 +615,14 @@ pub(crate) fn line_origin(
let font_size = atlas.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
let scale = atlas.scale;
let step_extra = step_extra_of(font.outline);
// Everything below returns DRAWN-space geometry ([`drawn_k`]): the string's drawn width, the
// caret cell's height, and the cell top all carry the 0581 rescale the glyph quads take.
let k = drawn_k(font.height, font_size);
// The line origin under justifyH (LEFT needs no width measure — the chat case).
let x0 = match justify.h {
JustifyH::Left => rect.min.x,
JustifyH::Center | JustifyH::Right => {
let w = measure_line_width(
let w = k * measure_line_width(
&mut atlas.font_system,
&atlas.glyphs,
&attrs,
@@ -610,18 +640,25 @@ pub(crate) fn line_origin(
};
// The single-line block's v_offset — [`layout_text_quads_inner`]'s law with block_h = 1 line,
// through the same ONE vertical snap ([`snap_block_top`]) so the caret/selection cell sits
// exactly on the drawn glyphs.
// exactly on the drawn glyphs — then the 0581 rescale about the same v anchor the draw
// scales its quads about (snap first, scale second: the draw's own order).
let top = if rect.height() > f32::EPSILON {
let v_offset = match justify.v {
JustifyV::Top => 0.0,
JustifyV::Middle => (rect.height() - font_size) * 0.5,
JustifyV::Bottom => rect.height() - font_size,
};
snap_block_top(rect.min.y + v_offset)
let snapped = snap_block_top(rect.min.y + v_offset);
let anchor_y = match justify.v {
JustifyV::Top => rect.min.y,
JustifyV::Middle => (rect.min.y + rect.max.y) * 0.5,
JustifyV::Bottom => rect.max.y,
};
anchor_y + (snapped - anchor_y) * k
} else {
rect.min.y
};
(x0, top, font_size)
(x0, top, font_size * k)
}
/// The client's SINGLE vertical anchor snap (`anchor_justify_snap 0x5cdf70` @`0x5ce051`,
@@ -1128,6 +1165,18 @@ mod seat_tests {
assert_eq!(snap_block_top(10.5), snap_block_top_law(10.5) + 1.0);
assert_eq!(snap_block_top(0.0), 1.0);
}
#[test]
fn the_measures_ride_the_drawn_space_factor() {
// [`drawn_k`] mirrors the 0581 rescale gate exactly: identity at a baked size and for
// height-None; the requested/snapped ratio between bakes (0989 — the era-scaled options
// window put every request between bakes, and the caret drifted 28% past its text).
assert_eq!(drawn_k(None, 8.0), 1.0);
assert_eq!(drawn_k(Some(8.0), 8.0), 1.0);
assert_eq!(drawn_k(Some(8.004), 8.0), 1.0); // inside the draw's 1e-3 gate
let k = drawn_k(Some(6.398), 8.203);
assert!((k - 6.398 / 8.203).abs() < 1e-6);
}
}
#[cfg(test)]
@@ -884,3 +884,52 @@ fn wmo_camera_void_audit() {
"an eye above the terrain lid must remain OUTSIDE"
);
}
/// **The Deeprun Tram's undersea tube — the map with no `Light.dbc` row.**
///
/// Map 369 carries no `Light.dbc` sphere at all (not even a falloff-0 global, unlike maps 0/1), so
/// every scrap of its atmosphere has to come from the building: the root's MFOG. `Subway.wmo`
/// authors four records, and the undersea stretch is record **2** — pos `(25, -1256, -117)`,
/// radii 154/247, colour RGB(30,53,100), end 236.1 yd, start scalar 0.05. The camera's group
/// there (`Subway_002`, flags `0x2805` ⇒ INTERIOR) names it: `fogIds = (2,0,0,0)`.
///
/// This pins the two links that have to hold for that fog to reach the frame — the down-ray seed
/// claiming the tunnel group, and the selector resolving record 2 over the record-0 seed — on a
/// **global (WDT `MODF`) WMO**, the placement shape the rest of this module's sites never exercise.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn deeprun_tram_undersea_claims_its_own_mfog() {
let subject = load_subject(r"World\wmo\Dungeon\AZ_Subway\Subway.wmo", None);
let model = &subject.model;
// The undersea tunnel group, by its authored fog list rather than a hardcoded index.
let gi = 2usize;
let nav = &model.group_nav[gi];
assert_eq!(
nav.fog_indices,
[2, 0, 0, 0],
"g02 should name MFOG record 2"
);
assert_eq!(nav.flags & EXTERIOR, 0, "g02 should be an INTERIOR group");
assert!(model.fogs.len() >= 2, "4 MFOG records; no count==1 bail");
let eye = interior_standing_eye(model, gi).expect("a standing eye inside g02");
println!("eye_local = {eye:?}");
let seeds = subject.seeds(eye);
println!("down-ray seeds = {seeds:?}");
assert_eq!(
seeds.in_group,
Some(gi),
"the down-ray must claim the tunnel group — otherwise no MFOG ever engages"
);
let target = crate::wmo_portal::fog::select_wmo_fog(&model.fogs, nav.fog_indices, eye)
.expect("record 2 covers this eye, so a target must resolve");
println!("resolved MFOG target = {target:?}");
let rgb = target.color.map(|c| (c * 255.0).round() as i32);
assert_eq!(rgb, [30, 53, 100], "the undersea record's authored colour");
assert!(
(target.end - 236.1).abs() < 0.5,
"end {} should be the record's 236.1 yd",
target.end
);
}