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:
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(°enerate, true, 30.0), 0.0);
|
||||
assert_eq!(world_motion_kept(°enerate, 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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,7 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
mod commands;
|
||||
pub(crate) mod commands;
|
||||
mod edit;
|
||||
mod event;
|
||||
mod feed;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¯os);
|
||||
// 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(¯os), 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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})"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user