the spell owns its cast ladder, and landing checks the other platforms

- the cast ladder moves under the spell: try, target bind, the usable walk and the refusal rungs live in one place, and the cast reply sends a chained cast itself (2330)
- every server packet decodes through one exhaustive match, and an opcode-coverage script joins the reference's 387 handled opcodes against ours (2331)
- a landing that touches a platform seam compiles Linux and Windows first, and the seven WMO portal-cull audits join the tests (2331)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 12:12:24 +07:00
co-authored by Claude Opus 5.5
parent c2d6f7c858
commit 6d5328994b
54 changed files with 1470 additions and 1277 deletions
+3 -1
View File
@@ -33,7 +33,9 @@ tracy = ["bevy/trace_tracy"]
# Opt-in Chrome-trace profiling: `cargo run --features trace_chrome` writes a `trace-<ts>.json` of
# every system's per-run span next to the working dir. Tracy's offline twin — it needs no GUI and no
# live capture, so a per-system cost question is answerable from a terminal (and from a run someone
# else recorded). Aggregate it with `scripts/tracesum.py`.
# else recorded). Its readers: `scripts/tracesum.py` (per-span totals), `scripts/framecost.py`
# (the per-frame cost ledger, 0718) and `scripts/tracebands.py` (a window band-mapped into
# replaceable units, 1413/1414).
trace_chrome = ["bevy/trace_chrome"]
[dependencies]
@@ -2252,6 +2252,38 @@ mod tests {
);
}
/// The parser's own header, read against the file it describes. `benilla_ui::bindings_xml`
/// quotes the shape of `Interface\FrameXML\Bindings.xml` — 228 live bindings, 94 `runOnUp`,
/// 13 `header`, 12 `hidden`, the commented-out `MOVEVIEW*` family that a text search counts and
/// a parser must not — and until 2331 the test that pinned those numbers lived beside the
/// parser, keyed on a `BENILLA_BINDINGS_XML` path nothing ever set, so it had never run. It
/// lives here because here is where the install is (`install_bindings`, off the player's own
/// chain); a wrong count is either a wrong file or a header that drifted from the client.
#[test]
fn the_installs_bindings_xml_reads_as_the_parsers_header_says() {
let Some(binds) = install_bindings() else {
return;
};
assert_eq!(
binds.len(),
228,
"1.12.1 ships 228 live bindings — the other six are inside a comment"
);
assert!(binds.iter().all(|b| !b.name.is_empty()));
assert!(
!binds.iter().any(|b| b.name.starts_with("MOVEVIEW")),
"the commented-out family must not register"
);
assert_eq!(binds.iter().filter(|b| b.run_on_up).count(), 94);
assert_eq!(binds.iter().filter(|b| b.header.is_some()).count(), 13);
assert_eq!(binds.iter().filter(|b| b.hidden).count(), 12);
assert_eq!(
binds[0].header.as_deref(),
Some("MOVEMENT"),
"the file opens on the MOVEMENT section"
);
}
/// The install's own `Bindings.xml`, parsed — `None` (and a skipped test) without a client.
fn install_bindings() -> Option<Vec<benilla_ui::bindings_xml::AddonBinding>> {
let data = benilla_formats::wow_data_or_skip!(None);
+2 -2
View File
@@ -346,7 +346,7 @@ pub(crate) fn cancel_auto_repeat_local(
/// are deliberately unmodelled, neither with a benilla reader: `[+0xc50]` ("locally initiated, not
/// server-confirmed") and `[+0xc54]` ("stop sent, awaiting the echo") — the latter's sole reader
/// image-wide is `0x5eccda`, inside `Attack 0x5ecb70`, which no benilla cast path enters while
/// engaged (see [`crate::ui_action::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
/// engaged (see [`crate::spell::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
/// the stop are `TriggerTutorial(0xa)` / `(0xb)` — the low-health / low-mana popups, not audio and
/// not part of this seam.
pub(crate) fn stop_attack_local(
@@ -476,7 +476,7 @@ pub(crate) fn toggle_attack_local(
/// The targeting side needs exactly this: `target::scan`'s `commit` runs the reference's
/// stop → select → re-swing law (`SetSelection 0x493540`'s own `0x493a08 call 0x5ecac0` and
/// `0x4938c8 call 0x5ecb70`), so every selection writer that can fire it has to carry both seams'
/// inputs. [`crate::ui_action::cast_send::CastLadder`] already carries them field by field for the
/// inputs. [`crate::spell::cast_send::CastLadder`] already carries them field by field for the
/// cast path and calls the free functions directly.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct AttackSeam<'w, 's> {
+5 -5
View File
@@ -197,7 +197,7 @@ pub(crate) struct DisplayedCursor(pub(crate) crate::target::WorldCursor);
fn drive_displayed_cursor(
world: Res<crate::target::WorldCursor>,
over_ui: Res<crate::ui_script::PointerOverUi>,
targeting: Res<crate::ui_action::targeting::SpellTargeting>,
targeting: Res<crate::spell::targeting::SpellTargeting>,
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
// The base last applied by the UI-entry restore below — `None` while the pointer is over the
// world, so re-entering the UI always restores once.
@@ -773,10 +773,10 @@ mod tests {
app.insert_resource(world);
app.insert_resource(crate::ui_script::PointerOverUi(over_ui));
app.insert_resource(DisplayedCursor(standing));
let mut targeting = crate::ui_action::SpellTargeting::default();
let mut targeting = crate::spell::SpellTargeting::default();
if armed {
// Feed Pet's own bare ITEM word.
targeting.enter(6991, crate::ui_action::CastCommit::Spell, 0x0010);
targeting.enter(6991, crate::spell::CastCommit::Spell, 0x0010);
}
app.insert_resource(targeting);
app.world_mut()
@@ -913,8 +913,8 @@ mod tests {
app.insert_resource(CAST_GREY);
app.insert_resource(crate::ui_script::PointerOverUi(true));
app.insert_resource(DisplayedCursor(CAST_GREY));
let mut targeting = crate::ui_action::SpellTargeting::default();
targeting.enter(6991, crate::ui_action::CastCommit::Spell, 0x0010);
let mut targeting = crate::spell::SpellTargeting::default();
targeting.enter(6991, crate::spell::CastCommit::Spell, 0x0010);
app.insert_resource(targeting);
app.world_mut()
.run_system_once(drive_displayed_cursor)
+2 -2
View File
@@ -2881,7 +2881,7 @@ mod tests {
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.init_resource::<crate::ui_trade::BlockTrades>()
.init_resource::<crate::ui_action::AutoSelfCast>()
.init_resource::<crate::spell::AutoSelfCast>()
.init_resource::<crate::perf::FpsJournalSetting>()
.init_resource::<crate::text_filter::TextFilterSwitches>()
.init_resource::<crate::game_tip::GameTipSetting>()
@@ -2916,7 +2916,7 @@ mod tests {
app.add_observer(crate::target::on_cvar);
},
|app| {
app.add_observer(crate::ui_action::on_cvar);
app.add_observer(crate::spell::cast_target::on_cvar);
},
|app| {
app.add_observer(crate::combat_text::on_cvar);
+2 -2
View File
@@ -582,8 +582,8 @@ fn drain_death(
script: Option<NonSendMut<UiScript>>,
mut death_net: ResMut<DeathNet>,
net: Res<NetCommands>,
targeting: crate::ui_action::cast_target::CastTargeting,
mut ladder: crate::ui_action::CastLadder,
targeting: crate::spell::cast_target::CastTargeting,
mut ladder: crate::spell::CastLadder,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
// The soulstone leg is a real arm-290 caller in the reference's own census (wow-re
// `bind-confirm-law.md`: 290 ← UseSoulstone · UseInventoryItem · UseAction · UseContainerItem),
+14 -2
View File
@@ -350,7 +350,7 @@ impl PluginGroup for GamePlugins {
// The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from
// PlayerActions.spells per the byte-verified admission/order, drives the stock shapeshift bar through
// 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).
// UiActionPlugin (shares `Spells`) and SpellPlugin (the `usable` walk, the cast tail).
.add(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
@@ -953,10 +953,17 @@ pub(crate) mod schedule_tests {
/// the row behind it is, and it is written by whoever owns it. A scene is also built **once**,
/// the frame its model asset lands, against a reaper that fires only when a tile dies.
///
/// **2,886 (decision 2330)** — read off the dump on the tree that lands, after the
/// `modalNextSpell` outbox's drain retired: `drain_chain_casts` held the whole cast ladder
/// (`CastLadder`'s fourteen parameters and `CastTargeting`'s ten) with no declared order
/// against anything but `UiInput`, and the cast reply's handler now casts the chain itself,
/// as a one-shot outside the schedule. The three targeting systems the same record moved
/// from `UiActionPlugin` to `SpellPlugin` kept their orders and their sets.
///
/// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with
/// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is
/// about a resource that commutes by construction, the claim belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 2_975;
const UPDATE_ACTIONABLE_CEILING: usize = 2_886;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
@@ -1146,7 +1153,12 @@ pub(crate) mod schedule_tests {
/// The structural half of 2265 §A3's executor question — is the prize of collapsing the VM
/// (and the audio layer) to one `Send` owner correctness only, or correctness plus
/// parallelism? Prints the serial depth of `Update` today and under each collapse.
///
/// An instrument, not a gate: it asserts nothing, so it can only fail by panicking inside the
/// census, and it boots a headless client to print four numbers. Ignored for the same reason
/// `reference_ui`'s chain reports are (2331); run it by hand when the A3 question is live.
#[test]
#[ignore = "instrument: run by hand (2265 §A3's executor census) — cargo test -p benilla-app --lib concurrency_census -- --ignored --nocapture"]
fn concurrency_census() {
let mut app = headless_client();
let c = census(&mut app, Update);
+3
View File
@@ -21,6 +21,9 @@
//! The audio-thread half never allocates or blocks (a fixed ring push per sample; overflow is
//! counted, not waited on). The ring holds [`RING_SECONDS`] of stereo audio — the writer wakes
//! every [`FLUSH_EVERY`] and would have to stall ~50× past its cadence before a sample drops.
//!
//! Read a capture back with `scripts/mixsum.py <path.wav>`: what the mix was doing, when, in
//! numbers (clipping, level, silence, steps).
use bevy::prelude::*;
use kira::effect::{Effect, EffectBuilder};
+2
View File
@@ -1,4 +1,6 @@
//! **Measuring mode** — `$WOW_SOUND_PROBE`: record a session so a sound report becomes evidence.
//! `scripts/soundprobe.sh` starts the client in it (the `play` profile, with the banner that says
//! when to press the mark key); `scripts/soundprobe.py` reads the capture back.
//!
//! ## Why this exists
//!
@@ -38,8 +38,8 @@ use bevy::prelude::*;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use super::{cast_target, reagent_totem_refusal, state, CastErrors, Spells};
use crate::spell::AutoRepeatActive;
use super::{cast_target, validator, AutoRepeatActive};
use crate::ui_action::{reagent_totem_refusal, CastErrors, Spells};
/// **What the commit writes** — `SendCast 0x6e54f0`'s one branch on item-present. The sender
/// discriminates on whether the pending-cast block's guid (`0xceac48`, filled at `6e4f8d`–`6e4fa6`
@@ -260,7 +260,7 @@ impl CastLadder<'_, '_> {
/// (`0x6e5930`'s `SetSheatheState(2,1,1)` — the echo START re-requests it, idempotent), an
/// auto-repeat spell sets the sticky armed state (`0x6e593b`'s `|= 0x200`, the standing Load/Hold
/// idle's gate — decision 0099 phase 5), and the resolved `CMSG_CAST_SPELL` goes out. Shared by
/// [`super::drain::drain_action_uses`] (a SPELL-kind action button) and
/// `ui_action`'s `drain_action_uses` (a SPELL-kind action button) and
/// `ui_spellbook::drain_spell_casts` (a spellbook cast, decision 0216 §8) — ONE cast-send path, so
/// the follow-through can't drift between the two spell sources (the root-cause rule: never
/// duplicate a send path).
@@ -461,7 +461,7 @@ fn send_spell_cast(
.self_pos
.zip(ctx.range.target_pos)
.map(|(a, b)| a.distance_squared(b));
if let Some(reason) = state::cast_range_refusal(
if let Some(reason) = validator::cast_range_refusal(
d,
row,
ctx.range.self_reach,
@@ -537,7 +537,7 @@ fn send_spell_cast(
// three arms to the reference's six by 1925), which sits ABOVE its mounted block below — so a
// stunned mounted caster reads the stun.
let self_fields = ctx.rel.self_store.map(|s| &s.0);
if let Some(reason) = state::cast_cc_refusal(
if let Some(reason) = validator::cast_cc_refusal(
self_fields.map_or(0, |f| f.unit_flags()),
self_fields.and_then(|f| f.unit_health()),
// The charm arm asks whether somebody ELSE holds the reins (`60994d`'s active-player
@@ -586,7 +586,7 @@ fn send_spell_cast(
// caster instead of erroring, so without this check the message can never appear. (0948
// resolved 0481's named micro-divergence: the range gate now runs before this one, the
// ref's own order — mounted∧out-of-range reads "Out of range." on both.)
if state::cast_mounted_refusal(
if validator::cast_mounted_refusal(
ctx.rel
.self_store
.is_some_and(|s| s.0.unit_mount_display_id() > 0),
@@ -602,8 +602,8 @@ fn send_spell_cast(
// while swimming" for the mount/Travel-Form/food set, 0x58 "Can only use while swimming" for
// Aquatic Form. The gate must be local: vmangos's CheckCast has no shapeshift or food water
// arm at all, so without this the server grants a druid aquatic form standing on dry land
// (ledger B176) and lets you eat mid-swim (B155's eating half). The condition is [`state`]'s.
if let Some(reason) = state::cast_water_refusal(ctx.self_move_flags, def) {
// (ledger B176) and lets you eat mid-swim (B155's eating half). The condition is [`validator`]'s.
if let Some(reason) = validator::cast_water_refusal(ctx.self_move_flags, def) {
debug!("ui_action: cast {spell_id} refused locally — water side ({reason:#x})");
cast_errors.push_local(spell_id, reason);
return;
@@ -614,7 +614,7 @@ fn send_spell_cast(
// client's own reason 0x2e "Can't do that while moving" and NEVER sends. The gate must be
// local: vmangos accepts the sent cast (its CheckCast moving-reject covers only
// autorepeat/sit-still spells) and then its movement interrupt cancels it mid-bar — the
// start-then-die cast bar this gate removes. The full condition is [`state`]'s.
// start-then-die cast bar this gate removes. The full condition is [`validator`]'s.
if let Some(d) = def {
let caster_level = ctx
.rel
@@ -622,7 +622,7 @@ fn send_spell_cast(
.and_then(|s| s.0.unit_level())
.unwrap_or(0);
let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level));
if state::cast_moving_refusal(ctx.self_move_flags, cast_time_ms, def) {
if validator::cast_moving_refusal(ctx.self_move_flags, cast_time_ms, def) {
debug!("ui_action: cast {spell_id} refused locally — moving (0x2e)");
cast_errors.push_local(spell_id, 0x2e);
return;
@@ -773,7 +773,7 @@ fn send_spell_cast(
// the cast's bound unit target, unless one is already running (`0x60ecb0` over
// `[player+0xc48]`; our mirror is the wire-echoed `Engaged`). The start is the attack path's
// own pair (`0x6131a0` → `0x5ecb70`): melee-sheath SNAP + `CMSG_ATTACKSWING` — the same two
// edges as the Attack button's arm in [`super::drain::drain_action_uses`]. Path-independent
// edges as the Attack button's arm in `ui_action`'s `drain_action_uses`. Path-independent
// in the ref (button/spellbook/CastSpellByName share the one tail) — matching our one send
// seam.
if let (Some(d), Some(guid)) = (def, target) {
@@ -870,7 +870,7 @@ mod tests {
world.init_resource::<CastErrors>();
world.init_resource::<AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<super::super::targeting::SpellTargeting>();
world.init_resource::<crate::spell::SpellTargeting>();
// The sheath request the commit tail writes for a ranged spell — a bare World has no
// message storage until it is asked for.
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
@@ -1053,7 +1053,7 @@ mod tests {
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == SERPENT_STING),
"the sting goes out first"
);
// What `cast_result` would queue, handed to the one send path the drain uses.
// What `cast_result` returns, and `on_cast_result` hands to the ladder (2330).
send_at(&mut world, AUTO_SHOT, MOB);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == AUTO_SHOT),
@@ -1361,7 +1361,7 @@ mod tests {
..Default::default()
},
);
let mut spells = super::super::Spells::empty_for_tests();
let mut spells = crate::ui_action::Spells::empty_for_tests();
spells.catalog = benilla_formats::SpellCatalog::from_displays(displays);
world.insert_resource(spells);
// A caster holding 100 mana (field 23 = UNIT_FIELD_POWER1). Leaked: the one-shot
@@ -1460,7 +1460,7 @@ mod tests {
let commit = |world: &mut World, commit: CastCommit, bound: TargetedBind| {
world.insert_resource(crate::spell::PendingCast::default());
world
.resource_mut::<super::super::targeting::SpellTargeting>()
.resource_mut::<crate::spell::SpellTargeting>()
// The lock word — the one that answers both the bag and the world seam.
.enter(HEARTHSTONE, commit, 0x4800);
world
@@ -1476,9 +1476,7 @@ mod tests {
Ok(ClientCommand::CastSpellAtDest { dest: DEST, .. })
));
assert!(
!world
.resource::<super::super::targeting::SpellTargeting>()
.active(),
!world.resource::<crate::spell::SpellTargeting>().active(),
"the commit clears the one word"
);
assert!(
@@ -240,7 +240,11 @@ pub(crate) struct CastTargeting<'w, 's> {
reputations: Res<'w, Reputations>,
self_transform: Query<'w, 's, &'static Transform, With<SelfPlayer>>,
transforms: Query<'w, 's, &'static Transform>,
player: Res<'w, crate::player::Player>,
/// `Option`: the body exists only in world, and `HandleCastResult` is a one-shot handler
/// that must fetch its parameters on any built client (decision 2330 — the reply casts the
/// `modalNextSpell` chain through the ladder). No body, no movement: the moving gate passes
/// and the server stays the net, as for every other untestable input.
player: Option<Res<'w, crate::player::Player>>,
}
impl CastTargeting<'_, '_> {
@@ -277,7 +281,7 @@ impl CastTargeting<'_, '_> {
.next()
.and_then(|s| s.0.player_inv_slot(EQUIPMENT_SLOT_MAINHAND))
.filter(|&g| g != 0),
self_move_flags: self.player.move_flags(),
self_move_flags: self.player.as_deref().map_or(0, |p| p.move_flags()),
}
}
}
@@ -293,6 +297,13 @@ const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
#[derive(bevy::prelude::Resource)]
pub(crate) struct AutoSelfCast(pub(crate) bool);
/// `autoSelfCast`'s change callback (decision 2303): a flag.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut auto: ResMut<AutoSelfCast>) {
if ev.is("autoSelfCast") {
auto.0 = ev.flag();
}
}
impl Default for AutoSelfCast {
fn default() -> Self {
Self(true)
+5 -18
View File
@@ -2,12 +2,12 @@
//! cast request (`SPELLCAST` @`0xceac48` + the inflight id `0xceca88` → [`PendingCast`]), the
//! queued on-next-swing strike ([`QueuedMeleeSpell`], our second slot for what the reference
//! keeps in the one id — [`Inflight`] re-joins them), the running channel (`0xceac58` →
//! [`ActiveChannel`]), the auto-repeat key (`0xceac30` → [`AutoRepeatActive`]), the
//! `modalNextSpell` outbox ([`ChainCasts`]) — and the local abort that ends a cast the same
//! tick a move, a jump or Esc lands (`AbortCast 0x6e4940` → [`local_self_cancel`]).
//! [`ActiveChannel`]), the auto-repeat key (`0xceac30` → [`AutoRepeatActive`]) — and the local
//! abort that ends a cast the same tick a move, a jump or Esc lands (`AbortCast 0x6e4940` →
//! [`local_self_cancel`]).
//!
//! Written by the spell's packet handlers ([`super::net`]), the one cast path
//! (`crate::ui_action::CastLadder`) and the attack seams; read by the feeds — the cast bar
//! ([`super::CastLadder`]) and the attack seams; read by the feeds — the cast bar
//! (`crate::ui_cast`), the action state, the target chain. Until decision 2328 this lived in
//! `ui_cast` and `ui_action`; it is the state 2265 §A7 named as having no owner.
@@ -202,7 +202,7 @@ impl PendingCast {
/// `CMSG_ATTACKSTOP` + `CMSG_CANCEL_CAST`), reached by /stopattack, the Attack-button toggle,
/// **an auto-repeat press** (`0x6e5976`), target change/death, mount, interact — never movement.
/// That chain is [`crate::creature_anim::stop_attack_local`], and the auto-repeat arm inside
/// [`crate::ui_action::cast_send`]'s commit runs it whole: starting Auto Shot un-queues the
/// [`crate::spell::cast_send`]'s commit runs it whole: starting Auto Shot un-queues the
/// strike, which is what darkens its checked ring. Every attack edge reaches that seam since
/// 1044 — the Attack toggle, a target switch, a click-off, the ring's death teardown.
///
@@ -342,19 +342,6 @@ impl ActiveChannel {
#[derive(Resource, Default)]
pub(crate) struct AutoRepeatActive(pub Option<u32>);
/// **The `modalNextSpell` chain's queue** — spells the *client* casts on its own, one per
/// `SMSG_CAST_RESULT` that names a spell with a non-zero `Spell.dbc` column 38
/// ([`benilla_formats::SpellDisplay::modal_next_spell`]). Written by [`super::net`]'s
/// `cast_result`, drained through the one cast path by `ui_action`'s `drain_chain_casts`.
///
/// It exists because the reference's chain runs *inside* `HandleCastResult 0x6e7330`
/// (`0x6e74aa call 0x6e5a90` → `TryCast`) and ours cannot: the net drain and
/// [`crate::ui_action::CastLadder`] want the same half-dozen resources, so a direct call is a Bevy param conflict.
/// A one-frame queue is the seam — and it keeps the rule that nothing sends a cast except the
/// ladder.
#[derive(Resource, Default)]
pub(crate) struct ChainCasts(pub(crate) Vec<u32>);
/// ── The local self-cancel (decisions 0256 open item 2 / 0444 / 0445): move/jump/Esc mid-cast
/// ends the cast **locally**, the same client tick — the app-side mirror of `AbortCast
/// 0x6e4940`. The whole trigger chain is **VERIFIED** (wow-re `move-selfcancel.md`, the
+52 -22
View File
@@ -1,37 +1,54 @@
//! **The spell** — the reference's `Spell_C` owns the cast lifecycle, the cooldowns, the
//! channels, the spell-modifier tables and its 22 packet registrations in one translation unit;
//! benilla had no such owner (2265 §A7). This module is that owner: the in-flight slot and the
//! local cancel ([`inflight`]), the cooldown store ([`cooldowns`]), the talent modifier tables
//! ([`mods`]) and the packet handlers ([`net`]) — decisions 2324 and 2328.
//! **The spell** — `Spell_C`, the reference's cast unit (wow-re `system/spell/spell.md`): the
//! cast ladder and its commit ([`cast_send`] — `TryCast 0x6e4b60` → `SendCast 0x6e54f0`), the
//! target bind ([`cast_target`] — `ArmCast 0x6e5250` / `BindTarget 0x6e5b40`), the requirement
//! validator's rungs ([`validator`] — `0x6094f0`), the usable walk ([`usable`] —
//! `IsSpellUsableNow 0x6e3d60`), the targeting cursor and its flag_word ([`targeting`] —
//! `0xcecac0`), the in-flight slot ([`inflight`]), the cooldown store ([`cooldowns`]), the
//! talent modifier tables ([`mods`]) and the packet handlers that fold into them ([`net`]).
//!
//! What it delegates, as the reference delegates it: the wire to `net`, the units to the object
//! layer, the spell table to `ui_action::Spells` (the DBC catalog every face reads), the error
//! text and the VM to the `ui_*` feeds, the visuals and animation to `creature_anim`. The one
//! cast path — the `TryCast` ladder — is still `ui_action`'s; it writes this module's state
//! through `crate::spell` like every other caller (2328 §Open).
//! What the reference delegates stays out, with its owner: the spell table
//! (`crate::ui_action::Spells`, the DBC's), the action store and the error text
//! (`crate::ui_action` — the `ui` boundary), the aura durations (`crate::ui_aura`), the pet bar
//! (`crate::ui_pet`), the cast bar's feed (`crate::ui_cast`). Decisions 2328 (the state) and 2330
//! (the ladder).
use bevy::prelude::*;
use crate::char_select::ClientState;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
use benilla_world::schedule::WorldStage;
use crate::char_select::ClientState;
use crate::ui_unit::UnitFeed;
mod cast_send;
pub(crate) mod cast_target;
pub(crate) mod cooldowns;
mod inflight;
mod mods;
pub(crate) mod net;
pub(crate) mod targeting;
pub(crate) mod usable;
pub(crate) mod validator;
// The one cast path, and the only way in: every caster surface takes [`CastLadder`] as a single
// SystemParam and commits through [`CastCommit`] — `send_spell_cast` itself is private to
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
pub(crate) use cast_send::{CastCommit, CastLadder, TargetedBind};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use cooldowns::Cooldowns;
pub(crate) use inflight::{
inflight, ActiveChannel, AutoRepeatActive, ChainCasts, LocalMoveStart, PendingCast,
QueuedMeleeSpell, SPELL_INTERRUPT_MOVEMENT,
inflight, ActiveChannel, AutoRepeatActive, LocalMoveStart, PendingCast, QueuedMeleeSpell,
SPELL_INTERRUPT_MOVEMENT,
};
pub(crate) use mods::{SpellModifiers, OP_COST};
// The target chain registers the cursor pre-empt + the click commits, and the spellbook/stance/
// craft drains thread the mode through the one cast-send path (decision 0792). `TargetingWants`
// travels with it because the chain also holds a *seam-specific* consumer — the ground reticle,
// which draws for the location word alone (decision 0943).
pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants};
/// The local cancel's slot in the frame: a feed that drains the edges it pushes (the cast bar)
/// orders itself `.after(LocalCancel)` so a move-edge's `Stop` reaches the VM the same frame it
/// happened — the `chain()` `ui_cast` held while both were its (2328).
/// The local self-cancel's slot in the frame: anything that reads the in-flight state after a
/// move/jump/Esc may have ended the cast this tick orders `.after(LocalCancel)` — the cast-bar
/// feed does, so the bar drops the same frame the cast does (2328).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct LocalCancel;
@@ -44,21 +61,34 @@ impl Plugin for SpellPlugin {
.init_resource::<ActiveChannel>()
.init_resource::<LocalMoveStart>()
.init_resource::<AutoRepeatActive>()
.init_resource::<ChainCasts>()
.init_resource::<Cooldowns>()
.init_resource::<SpellModifiers>()
.init_resource::<AutoSelfCast>()
.init_resource::<SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
.add_observer(cast_target::on_cvar)
.add_systems(
Update,
(
inflight::local_self_cancel
.in_set(UnitFeed)
.in_set(LocalCancel),
// After the net stage that merges the avatar's descriptor, and before the
// feeds that read a cost through it — so the family is this frame's, never
// last frame's, on the frame the avatar first resolves.
mods::track_class_family
.after(WorldStage::Net)
.before(UnitFeed),
// The targeting mode's ESC-chain halves (decision 0792): the state push
// rides the feeds (before the input pass runs `ToggleGameMenu`), the
// trigger drain follows it — same frame, so an ESC's cancel lands before
// the next frame's cursor drive reads the mode. The cursor pre-empt, the
// right-press cancel, and the click commits register in the TARGET chain
// (ordering against the classifier and the select click is theirs to own).
targeting::feed_targeting_to_vm.in_set(UnitFeed),
targeting::drain_stop_targeting.after(UiInput),
// The item half's commit (decision 0923) — the bag / paper-doll click seam's
// `0x495d60`. A UI drain like the others: after the input pass, so a click
// this frame binds this frame. It is deliberately NOT in the target chain —
// the clicks it consumes never reach the world.
targeting::commit_item_cast_on_pick.after(UiInput),
),
)
.add_systems(OnEnter(ClientState::InWorld), mods::clear_on_world_enter);
+2 -2
View File
@@ -68,7 +68,7 @@
//! | 11 | COOLDOWN | — |
//! | 12 | SPEED | — |
//! | 13 | *(no enumerator)* | never read |
//! | **14** | **COST** | [`OP_COST`] — `ui_action::usable::power_cost` |
//! | **14** | **COST** | [`OP_COST`] — `spell::usable::power_cost` |
//! | 15 | CRIT_DAMAGE_BONUS | never read |
//! | 16 | RESIST_MISS_CHANCE | never read |
//! | 17 | JUMP_TARGETS | — |
@@ -114,7 +114,7 @@ const BITS: usize = 64;
/// discriminators that settle the index's factor roles.
const CELLS: usize = BITS * OPS;
/// `SPELLMOD_COST` — the one op benilla reads today ([`crate::ui_action::usable::power_cost`], the
/// `SPELLMOD_COST` — the one op benilla reads today ([`crate::spell::usable::power_cost`], the
/// reference's `GetPowerCost 0x6e31b0` @ `6e32e3`). The module header's table names the other 28.
pub(crate) const OP_COST: u8 = 14;
+80 -43
View File
@@ -11,7 +11,11 @@ use benilla_formats::LearnAnnouncement;
use benilla_protocol::messages::{ActionButton, SpellCooldown};
use bevy::prelude::*;
use super::{ActiveChannel, AutoRepeatActive, Cooldowns, PendingCast, QueuedMeleeSpell};
use super::cast_target::CastTargeting;
use super::{
ActiveChannel, AutoRepeatActive, CastCommit, CastLadder, Cooldowns, PendingCast,
QueuedMeleeSpell,
};
use crate::creature_anim::{CastEvent, CastEventKind, Casting, SpellGoTargets};
use crate::ui_action::{CastErrors, PlayerActions, Spells, UiError, UiErrorKeys};
use crate::ui_aura::AuraDurations;
@@ -51,8 +55,9 @@ pub(super) fn register(app: &mut App) {
/// The cast lifecycle's state and catalogs, as one parameter — what every handler here folds
/// its packet into. The in-flight slot, the cooldowns and the modifiers are the spell's own
/// (2328); the action store, the errors, the spellbook's learned-tab list, the aura durations
/// and the pet bar are their windows', written here because the packet is the spell's.
/// (2328); the action store, the spellbook's learned-tab list, the aura durations and the pet
/// bar are their windows', written here because the packet is the spell's. The cast reply's
/// handler is the exception — it casts, so it takes the ladder instead ([`on_cast_result`]).
#[derive(SystemParam)]
pub(crate) struct Lifecycle<'w> {
self_guid: Res<'w, SelfGuid>,
@@ -60,8 +65,6 @@ pub(crate) struct Lifecycle<'w> {
spells: Option<Res<'w, Spells>>,
net: Res<'w, NetCommands>,
actions: ResMut<'w, PlayerActions>,
cast_errors: ResMut<'w, CastErrors>,
chain_casts: ResMut<'w, crate::spell::ChainCasts>,
learned_in_tab: ResMut<'w, LearnedInTab>,
cast_bar: ResMut<'w, CastBarFeed>,
pending: ResMut<'w, PendingCast>,
@@ -152,7 +155,38 @@ fn on_spell_superceded(
}
}
fn on_cast_result(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
/// What `HandleCastResult` reaches beyond the ladder — the reply's own scene.
#[derive(SystemParam)]
pub(crate) struct Reply<'w, 's> {
self_guid: Res<'w, SelfGuid>,
index: Res<'w, GuidIndex>,
cast_bar: ResMut<'w, CastBarFeed>,
casting: Query<'w, 's, &'static Casting>,
cast_events: MessageWriter<'w, CastEvent>,
play_seq: ResMut<'w, crate::creature_anim::PlaySeq>,
}
/// `HandleCastResult 0x6e7330` — the one handler that *casts*. A reply whose spell names a
/// `modalNextSpell` (column 38) chains it through the ladder in the same call, the reference's
/// `0x6e74aa call 0x6e5a90` → `TryCast` (decision 2330): so this handler takes [`CastLadder`]
/// and [`CastTargeting`] where the others take [`Lifecycle`] — the five spell-state fields the
/// two would share are the ladder's — and reaches the rest through [`Reply`]. Before 2330 the
/// chain was an outbox a `ui_action` drain emptied after the input pass; now the chained cast
/// arms the in-flight slot before the next packet is handled, as in the reference.
///
/// The chained cast goes out at the **null target guid** — `0x6e74a6 push ebx; push ebx` with
/// `ebx = 0` — so it binds through the ordinary target walk (`ArmCast 0x6e5250`: main-hand item
/// bit, then the explicit guid, then the current selection), which is what
/// [`CastTargeting::context`] hands the ladder when no guid is passed. And it takes **every
/// rung**: the reference chains through `0x6e5a90` → `TryCast 0x6e4b60`, the same entry a button
/// press uses, so a chained Auto Shot is range-checked, form-checked and GCD-checked exactly like
/// a pressed one, and refuses with the same red line.
fn on_cast_result(
In(ev): In<SessionEvent>,
mut ladder: CastLadder,
targeting: CastTargeting,
mut r: Reply,
) {
if let SessionEvent::CastResult {
spell_id,
success,
@@ -160,27 +194,30 @@ fn on_cast_result(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
arg,
} = ev
{
cast_result(
let chained = cast_result(
spell_id,
success,
reason,
arg,
&mut sc.commands,
&l.self_guid,
&l.index,
&mut l.cast_errors,
&sc.casting,
&mut sc.cast_events,
&mut l.cast_bar,
&mut l.pending,
&mut l.queued_melee,
&mut l.cooldowns,
&mut l.auto_repeat,
l.spells.as_deref(),
&l.net,
&mut l.chain_casts,
l.play_seq.next(),
&mut ladder.ecs,
&r.self_guid,
&r.index,
&mut ladder.cast_errors,
&r.casting,
&mut r.cast_events,
&mut r.cast_bar,
&mut ladder.pending,
&mut ladder.queued_melee,
&mut ladder.cooldowns,
&mut ladder.auto_repeat,
ladder.spells.as_deref(),
&ladder.commands,
r.play_seq.next(),
);
if let Some(next) = chained {
let ctx = targeting.context();
ladder.send(next, &ctx, CastCommit::Spell);
}
}
}
@@ -611,10 +648,8 @@ fn cast_result(
auto_repeat: &mut AutoRepeatActive,
spells: Option<&Spells>,
net: &crate::net::NetCommands,
// The `modalNextSpell` chain's outbox (`0x6e74aa`) — filled here, sent by the one cast path.
chain: &mut crate::spell::ChainCasts,
seq: u64,
) {
) -> Option<u32> {
debug!("net: cast result — spell {spell_id} success={success} reason={reason:?}");
// **Is this the reply to the cast we have outstanding?** (`0x6e7408 cmp ecx,[0xceca88]`.) Read
// BEFORE either arm touches the guard, because both the failure arm's clear below and the
@@ -732,6 +767,10 @@ fn cast_result(
// sting while Auto Shot is already running must NOT re-cast it, or every special shot would
// restart the repeat and reset its swing timer. We have no pending-record to refresh, so the
// equal branch is simply "send nothing" — the same observable.
//
// The chained spell is this function's **return**: `on_cast_result` hands it to the ladder
// in the same call (`0x6e74aa call 0x6e5a90`), so the chained cast takes every rung a press
// takes and arms the in-flight slot before the next packet is handled (decision 2330).
if in_flight {
pending.clear_if(spell_id);
if let Some(next) = spells
@@ -740,9 +779,10 @@ fn cast_result(
.filter(|&next| next != 0 && Some(next) != auto_repeat.0)
{
debug!("net: cast result {spell_id} chains modalNextSpell {next}");
chain.0.push(next);
return Some(next);
}
}
None
}
/// A unit began a non-triggered cast (`SMSG_SPELL_START`), instants included (`cast_time_ms == 0`)
@@ -998,7 +1038,7 @@ fn spell_go(
// **The GO-deferred melee auto-attack start** (`HandleSpellGo 0x6e7a70` @ `0x6e83c0`,
// wow-re `combat-feel-law.md` §A3; bytes re-read for decision 1593). This is the exact
// complement of the send-time tail in [`crate::ui_action::cast_send`]: a spell carrying
// complement of the send-time tail in [`crate::spell::cast_send`]: a spell carrying
// `AttributesEx2 & 0x100000` has its optimistic start *suppressed* there and armed here
// instead, so the swing begins only once the server confirms the strike landed. That is
// the whole 5875 stealth-opener class — Backstab, Garrote, Ambush, Cheap Shot, Shred,
@@ -2568,7 +2608,6 @@ mod tests {
&mut auto_repeat,
None,
&net,
&mut crate::spell::ChainCasts::default(),
1,
);
},
@@ -2973,7 +3012,8 @@ mod tests {
};
// One CAST_RESULT for `spell_id`, with `in_flight` armed as the outstanding cast and
// `running` as the live auto-repeat. Returns (what got queued, is the guard still armed).
// `running` as the live auto-repeat. Returns (what the reply chains, is the guard still
// armed).
let fire = |spell_id: u32, success: bool, in_flight: Option<u32>, running: Option<u32>| {
let mut app = App::new();
app.add_message::<CastEvent>()
@@ -2987,8 +3027,7 @@ mod tests {
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::spell::ChainCasts>();
.init_resource::<AutoRepeatActive>();
let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id();
app.world_mut()
.resource_mut::<GuidIndex>()
@@ -3006,7 +3045,8 @@ mod tests {
}
let (tx, _rx) = crossbeam_channel::unbounded();
let cat = spells();
app.world_mut()
let chained = app
.world_mut()
.run_system_once(
move |mut commands: Commands,
self_guid: Res<SelfGuid>,
@@ -3018,8 +3058,7 @@ mod tests {
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut cooldowns: ResMut<Cooldowns>,
mut auto_repeat: ResMut<AutoRepeatActive>,
mut chain: ResMut<crate::spell::ChainCasts>| {
mut auto_repeat: ResMut<AutoRepeatActive>| {
let net = crate::net::NetCommands(tx.clone());
cast_result(
spell_id,
@@ -3039,48 +3078,46 @@ mod tests {
&mut auto_repeat,
Some(&cat),
&net,
&mut chain,
1,
);
)
},
)
.unwrap();
let queued = app.world().resource::<crate::spell::ChainCasts>().0.clone();
let still_armed = app
.world()
.resource::<PendingCast>()
.in_flight(Instant::now());
(queued, still_armed)
(chained, still_armed)
};
// The ordinary case: the sting lands, and Auto Shot follows by itself.
assert_eq!(
fire(SERPENT_STING, true, Some(SERPENT_STING), None),
(vec![AUTO_SHOT], false),
(Some(AUTO_SHOT), false),
"a successful sting chains Auto Shot, and clears the in-flight guard first"
);
// And a failed one does too — both results converge on the same block.
assert_eq!(
fire(SERPENT_STING, false, Some(SERPENT_STING), None).0,
vec![AUTO_SHOT],
Some(AUTO_SHOT),
"a FAILED sting chains it as well (`0x6e735a jne` → the same `0x6e73eb`)"
);
// Already shooting: re-arm, never re-cast.
assert_eq!(
fire(SERPENT_STING, true, Some(SERPENT_STING), Some(AUTO_SHOT)).0,
Vec::<u32>::new(),
None,
"Auto Shot already running: the equal branch sends nothing"
);
// Auto Shot's own replies terminate the chain.
assert_eq!(
fire(AUTO_SHOT, true, Some(AUTO_SHOT), Some(AUTO_SHOT)).0,
Vec::<u32>::new(),
None,
"Auto Shot's own column 38 is 0 — no second hop"
);
// Not our in-flight cast (a proc's result, a stale reply): not ours to chain from.
assert_eq!(
fire(SERPENT_STING, true, Some(133), None).0,
Vec::<u32>::new(),
None,
"a reply for a spell we do not have in flight chains nothing"
);
}
@@ -351,7 +351,7 @@ mod tests {
world.spawn((SelfPlayer, Transform::default()));
world.resource_mut::<SpellTargeting>().enter(
2120,
crate::ui_action::CastCommit::Spell,
crate::spell::CastCommit::Spell,
word,
);
world
@@ -117,14 +117,14 @@ pub(crate) fn item_bind_verdict(
let sub_ok =
item.subclass < 32 && def.equipped_item_subclass_mask & (1u32 << item.subclass) != 0;
if !(class_ok && sub_ok) {
return ItemBind::Refuse(crate::ui_action::cast_target::ERR_INVALID_TARGET);
return ItemBind::Refuse(crate::spell::cast_target::ERR_INVALID_TARGET);
}
}
if def.equipped_item_inventory_type_mask != 0
&& !(item.inventory_type < 32
&& def.equipped_item_inventory_type_mask & (1u32 << item.inventory_type) != 0)
{
return ItemBind::Refuse(crate::ui_action::cast_target::ERR_INVALID_TARGET);
return ItemBind::Refuse(crate::spell::cast_target::ERR_INVALID_TARGET);
}
if let Some(new) = new {
if new.binds && !item.already_bound && !confirmed && item.inventory_type != 0 {
@@ -164,7 +164,7 @@ pub(crate) fn commit_item_cast_on_pick(
self_q: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
enchants: Option<Res<crate::items::Enchants>>,
mut parked: ResMut<EnchantConfirmItem>,
mut ladder: crate::ui_action::CastLadder,
mut ladder: crate::spell::CastLadder,
) {
let Some(mut script) = script else {
return;
@@ -222,7 +222,7 @@ pub(crate) fn commit_item_cast_on_pick(
ladder.commit_targeted(
spell_id,
commit,
crate::ui_action::cast_send::TargetedBind::Item(item_guid),
crate::spell::cast_send::TargetedBind::Item(item_guid),
);
continue;
};
@@ -284,7 +284,7 @@ pub(crate) fn commit_item_cast_on_pick(
ladder.commit_targeted(
spell_id,
commit,
crate::ui_action::cast_send::TargetedBind::Item(item_guid),
crate::spell::cast_send::TargetedBind::Item(item_guid),
);
}
}
@@ -300,7 +300,7 @@ pub(crate) fn commit_item_cast_on_pick(
ladder.commit_targeted(
spell_id,
commit,
crate::ui_action::cast_send::TargetedBind::Item(item_guid),
crate::spell::cast_send::TargetedBind::Item(item_guid),
);
}
}
@@ -354,12 +354,12 @@ mod tests {
);
assert_eq!(
item_target_refusal(&bracer, CLASS_ARMOR, 1, INVTYPE_CHEST),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET),
Some(crate::spell::cast_target::ERR_INVALID_TARGET),
"the inventory-type leg (495e4d) refuses a chestpiece"
);
assert_eq!(
item_target_refusal(&bracer, CLASS_WEAPON, 1, INVTYPE_WRIST),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET),
Some(crate::spell::cast_target::ERR_INVALID_TARGET),
"the class leg (495e10) refuses a weapon"
);
@@ -383,7 +383,7 @@ mod tests {
);
assert_eq!(
item_target_refusal(&poison, CLASS_ARMOR, SUB_SHIELD, 14),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET),
Some(crate::spell::cast_target::ERR_INVALID_TARGET),
"a shield is armor — the class leg alone stops it"
);
@@ -408,7 +408,7 @@ mod tests {
// nonsense, so the gate refuses instead of wrapping.
assert_eq!(
item_target_refusal(&poison, CLASS_WEAPON, 40, 13),
Some(crate::ui_action::cast_target::ERR_INVALID_TARGET)
Some(crate::spell::cast_target::ERR_INVALID_TARGET)
);
}
@@ -549,7 +549,7 @@ mod tests {
};
assert_eq!(
item_bind_verdict(&bracer_only, &enchanted, binder, false),
ItemBind::Refuse(crate::ui_action::cast_target::ERR_INVALID_TARGET)
ItemBind::Refuse(crate::spell::cast_target::ERR_INVALID_TARGET)
);
}
}
@@ -33,7 +33,7 @@
//! ([`EnchantConfirmItem`], the ref's `0xb4e3c0`) and return with the word still standing; their
//! Yes re-enters that same drain.
//!
//! All three end in **one commit tail** ([`crate::ui_action::CastLadder::commit_targeted`]): the
//! All three end in **one commit tail** ([`crate::spell::CastLadder::commit_targeted`]): the
//! packet (`SendCast 0x6e54f0`'s same block, two opcodes), the pending arm, the GCD, and the word
//! cleared.
//!
@@ -44,7 +44,7 @@
//! mode clears the word and sends **nothing**.
//!
//! Entry and the two press-cancel shapes live in the cast path itself: the resolver yields
//! [`crate::ui_action::cast_target::CastWireTarget::Targeting`] carrying the word, the one
//! [`crate::spell::cast_target::CastWireTarget::Targeting`] carrying the word, the one
//! cast-send path enters the mode here, a NEW spell's press aborts-and-proceeds (`TryCast
//! 6e4d62`), and the action bar's re-press of the SAME spell toggles the mode off (`UseAction
//! 0x4e5ee0`'s `GetTargetingSpellId`+`StopTargeting` — [`crate::ui_action::drain`]).
@@ -21,10 +21,10 @@ use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use crate::spell::cast_send::TargetedBind;
use crate::target::go_is_nearest;
#[cfg(test)]
use crate::target::{Hovered, HoveredObject};
use crate::ui_action::cast_send::TargetedBind;
use benilla_world::interact::WorldClick;
use super::TargetingWants;
@@ -46,7 +46,7 @@ pub(crate) fn commit_ground_cast_on_click(
// The point the PRESS ray hit, not this frame's — the reference's `+0x360`, written by the one
// down-edge pick and read unchanged at the release (decision 1122).
press: Res<crate::target::PressPick>,
mut ladder: crate::ui_action::CastLadder,
mut ladder: crate::spell::CastLadder,
) {
let occlusion = press.occlusion;
if !ladder.ground.active() {
@@ -122,7 +122,7 @@ pub(crate) fn commit_object_cast_on_click(
// The press's pick, as in the terrain leg — the object a gesture binds is the one it started
// on, whatever the mouse did after (decision 1122).
press: Res<crate::target::PressPick>,
mut ladder: crate::ui_action::CastLadder,
mut ladder: crate::spell::CastLadder,
) {
let (hovered, hovered_object) = (press.hovered, press.object);
if !ladder.ground.active() {
@@ -220,7 +220,7 @@ mod tests {
fn the_terrain_click_binds_source_or_dest_by_the_standing_word() {
const BLIZZARD: u32 = 10;
const AREA_DEATH: u32 = 265;
let commit = crate::ui_action::cast_send::CastCommit::Spell;
let commit = crate::spell::cast_send::CastCommit::Spell;
let ground = |world: &mut World| {
world.resource_mut::<crate::target::PressPick>().occlusion =
@@ -299,7 +299,7 @@ mod tests {
let (mut world, rx, id) = fixture();
world.resource_mut::<super::super::SpellTargeting>().enter(
OPENING,
crate::ui_action::cast_send::CastCommit::Spell,
crate::spell::cast_send::CastCommit::Spell,
LOCK_WORD,
);
hover_go(&mut world, 5.0);
@@ -322,7 +322,7 @@ mod tests {
/// difference between a dead click and a wrong packet.
#[test]
fn the_object_commit_holds_its_fire() {
let commit = crate::ui_action::cast_send::CastCommit::Spell;
let commit = crate::spell::cast_send::CastCommit::Spell;
// (a) Nothing armed: the click is not ours at all, and the reader is drained so it cannot
// replay as a commit the frame the mode turns on.
@@ -32,7 +32,7 @@ use crate::spell::Cooldowns;
use crate::spell::{SpellModifiers, OP_COST};
use crate::target::{can_attack, ring_reaction, Factions};
use super::Spells;
use crate::ui_action::Spells;
/// Leg 8's caster unit-flag test — the shared bit, declared once ([`crate::player`]).
use crate::player::UNIT_FLAG_IN_COMBAT;
+854
View File
@@ -0,0 +1,854 @@
//! **The requirement validator's rungs** — `Spell_C`'s `0x6094f0`, the press-time gates
//! `TryCast 0x6e4b60` runs between the target bind and the commit, plus the range compare
//! `IsTargetInRange 0x6e47b0` that `CanTargetUnit 0x6e4440` runs just before it. Pure
//! predicates over the caster's fields and the spell record, each answering the reference's own
//! reason code; [`super::cast_send`]'s ladder calls them in the reference's order, and the two
//! casts still outside the ladder (the world click's skin cast and the corpse insignia,
//! `crate::target::click`) ask the mounted one directly.
//!
//! Until decision 2330 these sat in `ui_action`'s dynamic-state feed, beside the `IsUsableAction`
//! family they are not part of: the feed greys a button, the validator refuses a press, and the
//! reference keeps them apart (`0x6094f0` is reached only from `TryCast`, never from the greying
//! predicate `0x6e3d60` — wow-re `spell.md`). Now they are the spell's, with the ladder.
use benilla_formats::{SpellDisplay, SpellRange};
/// The client's cast-fail reasons for the two range refusals ("Out of range." / "Target too
/// close" in `crate::ui_action`'s cast-fail table) — what `CanTargetUnit 0x6e4440` emits when
/// `IsTargetInRange 0x6e47b0` fails on its max² / min² compare.
pub(super) const ERR_OUT_OF_RANGE: u8 = 0x59;
pub(super) const ERR_TOO_CLOSE: u8 = 0x76;
/// The **pre-send** range refusal — the client's `TryCast` ladder runs `CanTargetUnit 0x6e4440`
/// → `IsTargetInRange 0x6e47b0` BEFORE `ArmCast`/`SendCast` (`wave-cast.md`, byte-verified), so
/// an out-of-range or too-close press fails locally and the commit tail — the ranged sheath
/// snap `0x6e5930` included — never runs. This is why a too-close Throw/Auto Shot must NOT draw
/// the ranged weapon. Squared 3D distance against [`benilla_formats::min_max_range`]'s {min, max}: beyond max² →
/// [`ERR_OUT_OF_RANGE`], inside a nonzero min² → [`ERR_TOO_CLOSE`]. Untestable inputs (no range
/// row, unknown distance) pass — the server still judges the cast.
pub(super) fn cast_range_refusal(
spell: &SpellDisplay,
row: Option<&SpellRange>,
self_reach: f32,
target_reach: Option<f32>,
dist_sq: Option<f32>,
) -> Option<u8> {
let (min, max) = benilla_formats::min_max_range(spell, row, self_reach, target_reach)?;
let d2 = dist_sq?;
if d2 > max * max {
return Some(ERR_OUT_OF_RANGE);
}
if min > 0.0 && d2 < min * min {
return Some(ERR_TOO_CLOSE);
}
None
}
/// `PreventionType` values (`Spell.dbc` column 165): which crowd-control flag can refuse this
/// spell locally. `0` = neither.
const PREVENTION_SILENCE: u32 = 1;
const PREVENTION_PACIFY: u32 = 2;
/// The **crowd-control leg** of the same requirement validator `0x6094f0`, sitting **above** its
/// mounted block (`0x609c6c`) — so a stunned mounted caster is told about the stun (decision 1904;
/// wow-re `equipped-item-and-cc-cast-gates.md` §2.1, byte-verified). It refuses before any packet,
/// which is why it must be local.
///
/// **Six arms, in the reference's order, first match wins** — 1863's fold-back recorded four:
///
/// | # | arm | gate | reason |
/// |---|---|---|---|
/// | 1 | CHARMED | `UNIT_FIELD_CHARMEDBY != 0` and the charmer is not us | `0x14` |
/// | 2 | STUNNED | bit 18 — **no per-spell gate, every spell** | `0x64` |
/// | 3 | SILENCED | bit 13 **and** `PreventionType == 1` | `0x60` |
/// | 4 | PACIFIED | bit 17 **and** `PreventionType == 2` | `0x5a` |
/// | 5 | FLEEING | bit 23 | `0x1e` |
/// | 6 | CONFUSED | bit 22 | `0x16` |
///
/// The asymmetry is the finding: STUNNED, FLEEING, CHARMED and CONFUSED carry no per-spell gate at
/// all, while SILENCED and PACIFIED run only where the spell's own `PreventionType` names them —
/// so a silence stops casts and leaves melee abilities alone, and a pacify does the reverse.
/// Reading `UNIT_FLAG_SILENCED` as "no casting" is the mistake this replaces.
///
/// **A dead caster skips all six** (`0x60980d`, a `jle` on `UNIT_FIELD_HEALTH`).
///
/// This ladder is **TryCast-only** — one caller, no address-takes — so a button does **not** grey
/// while stunned, silenced or pacified: it refuses on the press. (Fear, confuse and charm *are*
/// greyed, by a second copy of the same helpers inside `0x6e3d60`; that copy is not built here.)
///
/// Its byte-shape was nearly missed by a census twice: SILENCED's read is `f6 c4 20 test ah,0x20`,
/// a sub-register byte-lane form with no dword immediate, invisible to an immediate scan.
///
/// **The exemption scan is built** (decision 1946, closing 1925's deferral): each arm first asks
/// whether any of the caster's own auras grants immunity to what is blocking it — a scan of
/// `UNIT_FIELD_AURA[0..47]`'s raw spell ids for an aura of the arm's own type, then
/// [`benilla_formats::grants_immunity`] on each match. A hit **lifts** the refusal; a rejection
/// names the blocking mechanic and turns the arm's own reason into `0x8d`. Each arm scans for a
/// different set of aura types — charm `{6, 177, 2}`, stun `{12}`, silence `{27, 12, 60}`, pacify
/// `{25, 12, 60}`, fear `{7}`, confuse `{5}` — and those sets are the reference's, not a family
/// resemblance.
pub(crate) fn cast_cc_refusal(
unit_flags: u32,
health: Option<u32>,
charmed_by_other: bool,
spell: Option<&SpellDisplay>,
exempt: &mut impl FnMut(&[u32]) -> benilla_formats::CcExemption,
) -> Option<(u8, Option<u32>)> {
use crate::player::UNIT_FLAG_STUNNED;
/// `UNIT_FIELD_FLAGS` bits 13/17/22/23 (vmangos `UnitDefines.h`).
const UNIT_FLAG_SILENCED: u32 = 0x0000_2000;
const UNIT_FLAG_PACIFIED: u32 = 0x0002_0000;
const UNIT_FLAG_CONFUSED: u32 = 0x0040_0000;
const UNIT_FLAG_FLEEING: u32 = 0x0080_0000;
// The dead caster's skip (`0x60980d`): a corpse is refused by an earlier rung, not this one.
if health == Some(0) {
return None;
}
let prevention = spell.map_or(0, |d| d.prevention_type);
// One arm: ask its exemption scan first, and let its answer pick between silence and one of
// two messages. `exempt` returning `exempt: true` means one of the caster's own auras grants
// immunity to whatever is blocking — the arm is SKIPPED and the cast proceeds. Otherwise the
// refusal is `0x8d` "Can't do that while %s" when the scan named a mechanic, and the arm's own
// reason when it did not (decision 1946).
let mut arm = |aura_types: &[u32], own_reason: u8| -> Option<(u8, Option<u32>)> {
let scan = exempt(aura_types);
if scan.exempt {
return None;
}
Some(if scan.mechanic != 0 {
// The mechanic rides along as the message's `%s` — the one client-LOCAL refusal that
// carries an argument word (decision 1948).
(REASON_PREVENTED_BY_MECHANIC, Some(scan.mechanic))
} else {
(own_reason, None)
})
};
if charmed_by_other {
if let Some(r) = arm(&[6, 177, 2], 0x14) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_STUNNED != 0 {
if let Some(r) = arm(&[12], 0x64) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_SILENCED != 0 && prevention == PREVENTION_SILENCE {
if let Some(r) = arm(&[27, 12, 60], 0x60) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_PACIFIED != 0 && prevention == PREVENTION_PACIFY {
if let Some(r) = arm(&[25, 12, 60], 0x5a) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_FLEEING != 0 {
if let Some(r) = arm(&[7], 0x1e) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_CONFUSED != 0 {
if let Some(r) = arm(&[5], 0x16) {
return Some(r);
}
}
None
}
/// `SPELL_FAILED_PREVENTED_BY_MECHANIC` — "Can't do that while %s", `%s` being the blocking aura's
/// `SpellMechanic.dbc` name. Every crowd-control arm carries this **as well as** its own reason,
/// and which one appears is decided by whether the exemption scan named a mechanic.
const REASON_PREVENTED_BY_MECHANIC: u8 = 0x8d;
/// The **pre-send** mounted refusal (decision 0481) — the requirement validator `0x6094f0`'s
/// mounted block (`0x609c6c`, wow-re `mounted-action-gate.md` §5): a live
/// `UNIT_FIELD_MOUNTDISPLAYID` refuses the cast with reason `0x39` ("You are mounted") unless
/// the spell carries Attributes bit 24 (`0x01000000`, castable-while-mounted — the exemption
/// test at `0x609c6f`, the exact vmangos `SPELL_ATTR_ALLOW_WHILE_MOUNTED` mirror). A spell
/// with no loaded record has no exemption to claim — the gate holds (the ref always has the
/// record; refusing without data errs toward the ref's visible behavior). The sibling
/// mount-REQUIRED gate (reason 0x53, `0x609c05`) is recorded but unbuilt — no 1.12 player spell
/// exercises it. It is **two-armed** like the water pair below, not the single `+0x5c & 0x40`
/// read this comment used to claim: arm A is `AuraInterruptFlags & 0x40` (the `cl` at
/// `0x609c05` is the untouched low byte of `ecx = [esi+0x58]`, loaded 0x104 bytes earlier for
/// the unsheathed leg), arm B is `ChannelInterruptFlags & 0x40` at `0x609c3a` — corrected by
/// the 1063 §5 in wow-re `mounted-action-gate.md`. Note the plain mounted gate above is NOT in
/// that family: it is single-armed on `Attributes`.
pub(crate) fn cast_mounted_refusal(mounted: bool, spell: Option<&SpellDisplay>) -> bool {
mounted && spell.is_none_or(|d| d.attributes & 0x0100_0000 == 0)
}
/// The interrupt-flag water pair — the two bits that say *which side of the surface this aura
/// can live on*: `0x80` cancels it on ENTERING water, `0x100` on LEAVING it (vmangos
/// `AURA_INTERRUPT_UNDER_WATER_CANCELS` / `AURA_INTERRUPT_ABOVE_WATER_CANCELS`, bits 7/8). The
/// requirement validator reads the same pair to refuse the *cast* whose aura could not survive
/// the caster's current side.
const INTERRUPT_UNDER_WATER: u32 = 0x80;
const INTERRUPT_ABOVE_WATER: u32 = 0x100;
/// `AttributesEx & (IS_CHANNELED 0x4 | IS_SELF_CHANNELED 0x40)` — the gate on **arm B** of every
/// leg in this family (byte-verified `0x609d6a`/`0x609db1`). A channeled spell's requirement
/// bits live in its CHANNEL column, so the validator reads that column too — but only for a
/// spell that actually channels, which is what keeps Summon Baby Shark 25849 (`Channel 0x100`,
/// `AttributesEx 0`) out of the gate.
const ATTR_EX_CHANNELED: u32 = 0x44;
/// `SPELL_FAILED_ONLY_ABOVEWATER` — "Cannot use while swimming".
pub(super) const ERR_ONLY_ABOVEWATER: u8 = 0x50;
/// `SPELL_FAILED_ONLY_UNDERWATER` — "Can only use while swimming".
pub(super) const ERR_ONLY_UNDERWATER: u8 = 0x58;
/// The **pre-send** water refusal (decisions 1056 + 1063) — the requirement validator
/// `0x6094f0`'s environment block `0x609d33–0x609de2`, byte-carved by the wow-re §5 trio
/// (`system/spell/scratch/water-cast-gate.md`). It sits after the mounted/posture/day/night
/// legs and before the moving gate (`0x609de3`), which is where the ladder runs it — so a druid
/// standing on land is refused **before** the form gate `0x612480` ever evaluates.
///
/// One gate, two faces, and each face has **two arms** reading the same bit in two columns:
///
/// | reason | bit | arm A — `AuraInterruptFlags` (+0x58) | arm B — `ChannelInterruptFlags` (+0x5c), if channeled |
/// |---|---|---|---|
/// | [`ERR_ONLY_UNDERWATER`] `0x58` | `0x100` | `0x609d36` / swim `0x609d46` | `0x609d6f` / swim `0x609d7b` |
/// | [`ERR_ONLY_ABOVEWATER`] `0x50` | `0x80` | `0x609da4` / swim `0x609dac` | `0x609db5` / swim `0x609dc2` |
///
/// The swimming state is `[[caster+0x118]+0x40] & 0x200000` — the same wire-layout movement word
/// the moving gate reads, four times over, twice in each face.
///
/// **Arm B is not dead code, and leaving it out is visibly wrong**: Fishing rank 1 (7620)
/// carries `AuraInterruptFlags 0x80`, but ranks 2–4 (7731/7732/18248) carry **zero** and reach
/// the gate only through their `ChannelInterruptFlags 0x3cac`. Arm A alone would refuse rank 1
/// mid-swim and let its own upgrades through.
///
/// What the two bits actually select in the 5875 data: `0x80` is 245 rows — every mount, Travel
/// Form, the campfires, all of Food/Drink (`0x40080`), Fishing — and `0x100` is exactly five:
/// Aquatic Form 1066, the Lava/Slime swim auras 16455/16456, Master Angler 24346/24347.
///
/// **No exemption skips this block** (every jump into the run was enumerated) — unlike the
/// mounted gate's `Attributes` bit 24. And the gate must be LOCAL: vmangos's `CheckCast` gates
/// water only on `SPELL_AURA_MOUNTED` (`Spell.cpp:6379`), so it happily grants a druid aquatic
/// form on dry cobblestone (ledger B176, with the screenshot to prove it). An uncataloged spell
/// passes, like every other data-driven rung on this ladder.
///
/// The legs are evaluated `0x58` before `0x50`, the binary's own order; they are mutually
/// exclusive on the swim bit, so the order is fidelity, not behaviour.
pub(super) fn cast_water_refusal(move_flags_word: u32, spell: Option<&SpellDisplay>) -> Option<u8> {
let d = spell?;
let swimming = move_flags_word & crate::creature_anim::move_flags::SWIMMING != 0;
// Arm A always; arm B only for a spell that actually channels.
let requires = |bit: u32| {
d.aura_interrupt_flags & bit != 0
|| (d.attributes_ex & ATTR_EX_CHANNELED != 0 && d.channel_interrupt_flags & bit != 0)
};
if !swimming && requires(INTERRUPT_ABOVE_WATER) {
return Some(ERR_ONLY_UNDERWATER);
}
if swimming && requires(INTERRUPT_UNDER_WATER) {
return Some(ERR_ONLY_ABOVEWATER);
}
None
}
/// The AuraInterruptFlags-space MOVING|TURNING pair (`0x18`) — the moving gate's
/// "movement would matter anyway" arms test it on BOTH `AuraInterruptFlags` (+0x58) and
/// `ChannelInterruptFlags` (+0x5c), byte-verified at `0x609e0e`/`0x609e1c`.
const AURA_INTERRUPT_MOVING_TURNING: u32 = 0x18;
/// The **pre-send** moving refusal (decision 0862) — the requirement validator `0x6094f0`'s
/// moving block (`0x609de3–0x609e48`; the sole client-local emitter of reason `0x2e` "Can't do
/// that while moving". wow-re `moving-cast-gate.md`, §5 byte-verified): a press while the
/// caster's live CMovement flags carry any of {forward, backward, strafe L/R, JUMPING} refuses
/// locally — no packet, no cast bar, no GCD. Without it, vmangos *accepts* the cast (its
/// CheckCast moving-reject covers only autorepeat/sit-still spells, `Spell.cpp:5432`) and then
/// `Spell::update`'s 0.5-yd movement interrupt kills it — the start-then-cancel grief this gate
/// exists to prevent. The full reject condition, gate for gate:
///
/// - **entry**: `InterruptFlags & 0x1` (movement-interruptible — instants without it pass);
/// - **the movement word**: the WIRE `MovementFlags` layout (`[unit+0x9a8]+0x40`), mask
/// `0x200f` = forward|backward|strafe + JUMPING — turning and pitch are outside it, and so is
/// FALLINGFAR (`0x4000`; the client has NO falling/Stuck exemption — that's vmangos-only);
/// - **exemption**: an auto-repeat spell (`AttributesEx2 & 0x20` — Auto Shot, Shoot) never
/// refuses, whatever else it carries;
/// - **would movement matter**: a nonzero resolved cast time ([`super::Spells::cast_time_ms`]),
/// OR the [`AURA_INTERRUPT_MOVING_TURNING`] bits on the aura/channel interrupt columns — the
/// OR-arms are how a zero-cast-time *channel* is still refused at initiation.
///
/// An uncataloged spell passes (every record-read above needs the row; the ladder's other
/// data-driven legs — cooldown, GCD, range — share the disposition, and the server's own
/// interrupt stays the safety net). In the validator's order this sits after the mounted block
/// and before the shapeshift-form leg (`0x609e50`), which is where [`super::cast_send`]'s ladder
/// runs it.
pub(super) fn cast_moving_refusal(
move_flags_word: u32,
cast_time_ms: u32,
spell: Option<&SpellDisplay>,
) -> bool {
use crate::creature_anim::move_flags;
// The verified 0x200f: ANY_MOVE (0xf) | FALLING (0x2000), in our identical wire layout.
const MOVING_MASK: u32 = move_flags::ANY_MOVE | move_flags::FALLING;
let Some(d) = spell else { return false };
d.interrupt_flags & crate::spell::SPELL_INTERRUPT_MOVEMENT != 0
&& move_flags_word & MOVING_MASK != 0
&& !d.auto_repeat()
&& (cast_time_ms != 0
|| d.aura_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0
|| d.channel_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0)
}
#[cfg(test)]
mod tests {
use super::*;
fn spell_with_range(range_index: u32, attributes: u32) -> SpellDisplay {
SpellDisplay {
range_index,
attributes,
..Default::default()
}
}
/// The pre-send refusal (`IsTargetInRange 0x6e47b0`'s two compares over the resolved
/// bounds): Auto Shot's {8, 35} row + the unit reach pad — a point-blank target refuses
/// TOO_CLOSE, a distant one OUT_OF_RANGE, the sweet spot passes; untestable inputs pass.
#[test]
fn cast_range_refusal_follows_the_two_compares() {
let d = spell_with_range(114, 0);
let auto_shot = SpellRange {
min: 8.0,
max: 35.0,
flags: 0,
};
let reach = Some(1.5);
// Both bounds carry the bare reach pad (self 1.5 + target 1.5): min = 11, max = 38.
let refuse = |d2: f32| cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, Some(d2));
assert_eq!(refuse(3.0 * 3.0), Some(ERR_TOO_CLOSE));
assert_eq!(refuse(20.0 * 20.0), None);
assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE));
// The regression: a min-0 ranged row (Fireball/Shadow Bolt) must pass point-blank —
// its min never grows a reach pad — while the max compare still holds.
let fireball = SpellRange {
min: 0.0,
max: 35.0,
flags: 0,
};
let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), 1.5, reach, Some(d2));
assert_eq!(refuse(0.1), None);
assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE));
// A melee-family row has min 0 — never TOO_CLOSE, still OUT_OF_RANGE beyond reach.
let melee = SpellRange {
min: 0.0,
max: 5.0,
flags: 1,
};
let melee_spell = spell_with_range(2, 0);
assert_eq!(
cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(0.1)),
None
);
assert_eq!(
cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(15.0 * 15.0)),
Some(ERR_OUT_OF_RANGE)
);
// No row / no distance: nothing to test locally — pass (the server judges).
assert_eq!(cast_range_refusal(&d, None, 1.5, reach, Some(1.0)), None);
assert_eq!(
cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, None),
None
);
}
/// 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.
#[test]
fn cast_mounted_refusal_honors_the_bit24_exemption() {
let plain = SpellDisplay::default();
let exempt = SpellDisplay {
attributes: 0x0100_0000,
..Default::default()
};
assert!(cast_mounted_refusal(true, Some(&plain)));
assert!(!cast_mounted_refusal(true, Some(&exempt)));
assert!(!cast_mounted_refusal(false, Some(&plain)));
assert!(!cast_mounted_refusal(false, None));
assert!(cast_mounted_refusal(true, None), "no record, no exemption");
}
/// The water refusal (`0x609d33–0x609de2`) — both faces of the environment gate and both
/// arms of each face, on the real 5875 columns: Aquatic Form's `0x100` needs the SWIMMING
/// bit set, the mount/Travel-Form/food `0x80` needs it clear, Fishing's ranks reach it
/// through the CHANNEL column, and a spell carrying neither bit passes on both sides.
#[test]
fn cast_water_refusal_reads_both_sides_of_the_surface() {
use crate::creature_anim::move_flags as mf;
// Aquatic Form 1066: AuraInterruptFlags 0x100 — above-water cancels it, so the cast
// needs water. This is ledger B176: on land it must refuse, and it must not send.
let aquatic = SpellDisplay {
aura_interrupt_flags: 0x100,
..Default::default()
};
assert_eq!(
cast_water_refusal(0, Some(&aquatic)),
Some(ERR_ONLY_UNDERWATER)
);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&aquatic)), None);
// Travel Form 783 / every mount: 0x80 — entering water cancels it, so it refuses the
// other way round.
let travel = SpellDisplay {
aura_interrupt_flags: 0x80,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&travel)),
Some(ERR_ONLY_ABOVEWATER)
);
assert_eq!(cast_water_refusal(0, Some(&travel)), None);
// Food 433's shape (0x40080 = STANDING_CANCELS | UNDER_WATER_CANCELS) — the eating half
// of ledger B155 rides the same 0x80 arm.
let food = SpellDisplay {
aura_interrupt_flags: 0x4_0080,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&food)),
Some(ERR_ONLY_ABOVEWATER)
);
assert_eq!(cast_water_refusal(0, Some(&food)), None);
// ARM B (decision 1063): a CHANNELED spell's requirement bits live in its channel
// column. Fishing ranks 2–4's shape — AuraInterruptFlags zero, ChannelInterruptFlags
// 0x3cac (which carries 0x80), AttributesEx 0x21004004 (IS_CHANNELED).
let fishing_r2 = SpellDisplay {
aura_interrupt_flags: 0,
channel_interrupt_flags: 0x3cac,
attributes_ex: 0x2100_4004,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&fishing_r2)),
Some(ERR_ONLY_ABOVEWATER),
"arm A is empty here — only the channel column refuses it"
);
assert_eq!(cast_water_refusal(0, Some(&fishing_r2)), None);
// …and the `AttributesEx & 0x44` gate on arm B is load-bearing: Summon Baby Shark
// 25849's shape carries the channel bit but does not channel, so it is NOT gated.
let not_channeled = SpellDisplay {
channel_interrupt_flags: 0x100,
attributes_ex: 0,
..Default::default()
};
assert_eq!(cast_water_refusal(0, Some(&not_channeled)), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&not_channeled)), None);
// Cat Form 768 / Bear Form 5487 carry neither bit: usable on both sides, always.
let cat = SpellDisplay::default();
assert_eq!(cast_water_refusal(0, Some(&cat)), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&cat)), None);
// No record, nothing to read — the press passes, like every other data-driven rung.
assert_eq!(cast_water_refusal(0, None), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, None), None);
}
/// The water gate against the **real 5875 Spell.dbc** — the census the whole gate rests on.
/// The leg↔bit assignment is not a naming choice we could get backwards: exactly five rows
/// in the shipped data carry the water-REQUIRED bit, and Aquatic Form is one of them, while
/// the water-FORBIDDEN bit is a broad set led by the mounts, Travel Form and food/drink.
/// Skips without client data.
#[test]
fn the_water_bits_split_the_5875_data_the_way_the_gate_assumes() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
// Every spell the client would refuse OUT of water, by id.
let mut needs_water: Vec<u32> = catalog
.iter()
.filter(|(_, d)| d.aura_interrupt_flags & INTERRUPT_ABOVE_WATER != 0)
.map(|(id, _)| id)
.collect();
needs_water.sort_unstable();
assert_eq!(
needs_water,
vec![1066, 16455, 16456, 24346, 24347],
"the water-required set: Aquatic Form, the Lava/Slime swim auras, Master Angler"
);
// The other face: broad, and led by exactly the things you cannot do mid-swim.
let forbids_water = |id: u32| {
catalog
.get(id)
.is_some_and(|d| d.aura_interrupt_flags & INTERRUPT_UNDER_WATER != 0)
};
assert!(forbids_water(783), "Travel Form");
assert!(forbids_water(458), "Brown Horse");
assert!(forbids_water(433), "Food");
assert!(forbids_water(430), "Drink");
assert!(forbids_water(818), "Basic Campfire");
// ARM B's reason to exist, on the real rows (decision 1063). Fishing rank 1 carries the
// bit in the AURA column; its own upgrades carry NOTHING there and reach the gate only
// through the CHANNEL column. Arm A alone would refuse rank 1 mid-swim and let 2–4 fish.
let swim = crate::creature_anim::move_flags::SWIMMING;
assert!(forbids_water(7620), "Fishing rank 1 — arm A");
for rank in [7731, 7732, 18248] {
let d = catalog
.get(rank)
.unwrap_or_else(|| panic!("Fishing {rank} missing"));
assert_eq!(d.aura_interrupt_flags, 0, "Fishing {rank}: arm A is empty");
assert!(d.attributes_ex & ATTR_EX_CHANNELED != 0, "and it channels");
assert_eq!(
cast_water_refusal(swim, Some(d)),
Some(ERR_ONLY_ABOVEWATER),
"Fishing {rank} still refuses mid-swim, through the channel column"
);
assert_eq!(cast_water_refusal(0, Some(d)), None, "and fishes on shore");
}
// The forms that carry neither bit — usable on both sides, and the control that says the
// gate is reading a real per-spell column and not a class-wide accident.
for (id, name) in [(768, "Cat Form"), (5487, "Bear Form"), (2645, "Ghost Wolf")] {
let d = catalog.get(id).unwrap_or_else(|| panic!("{name} missing"));
assert_eq!(d.aura_interrupt_flags & 0x180, 0, "{name} is side-agnostic");
assert_eq!(cast_water_refusal(0, Some(d)), None, "{name} on land");
assert_eq!(cast_water_refusal(swim, Some(d)), None, "{name} swimming");
}
// And end to end, on the real rows: B176's exact press.
let aquatic = catalog.get(1066).expect("Aquatic Form");
assert_eq!(
cast_water_refusal(0, Some(aquatic)),
Some(ERR_ONLY_UNDERWATER),
"on land, Aquatic Form refuses — the B176 report"
);
assert_eq!(
cast_water_refusal(crate::creature_anim::move_flags::SWIMMING, Some(aquatic)),
None,
"swimming, it goes through"
);
}
/// The moving refusal (`0x609de3`) — every leg of the byte-verified condition: the
/// `InterruptFlags & 0x1` entry, the `0x200f` wire mask (turn/FALLINGFAR outside it,
/// JUMPING inside), the auto-repeat exemption, and the "would movement matter" arms (cast
/// time / aura / channel `0x18` bits).
#[test]
fn cast_moving_refusal_follows_the_validator_condition() {
use crate::creature_anim::move_flags as mf;
// Fireball's shape: ordinary timed cast (interrupt 0xf, nonzero cast time).
let timed = SpellDisplay {
interrupt_flags: 0xf,
..Default::default()
};
// Moving forward refuses; standing still doesn't.
assert!(cast_moving_refusal(mf::FORWARD, 1500, Some(&timed)));
assert!(!cast_moving_refusal(0, 1500, Some(&timed)));
// Strafe and JUMPING are in the mask; turn and FALLING_FAR are not (`0x200f`).
assert!(cast_moving_refusal(mf::STRAFE_LEFT, 1500, Some(&timed)));
assert!(cast_moving_refusal(mf::FALLING, 1500, Some(&timed)));
assert!(!cast_moving_refusal(mf::TURN_LEFT, 1500, Some(&timed)));
assert!(!cast_moving_refusal(mf::FALLING_FAR, 1500, Some(&timed)));
// An instant WITHOUT the movement interrupt bit passes (Fire Blast's shape)…
let instant = SpellDisplay {
interrupt_flags: 0xe,
..Default::default()
};
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&instant)));
// …and even WITH it, a zero cast time passes unless an 0x18 arm bites.
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&timed)));
// Arcane Missiles' shape: zero cast time, but the channel column's moving bits refuse
// at initiation (the OR-arm; 0x7c0c & 0x18 != 0).
let channel = SpellDisplay {
interrupt_flags: 0xf,
channel_interrupt_flags: 0x7c0c,
..Default::default()
};
assert!(cast_moving_refusal(mf::FORWARD, 0, Some(&channel)));
// The aura-column arm (food/drink sit-still bits).
let sit_still = SpellDisplay {
interrupt_flags: 0x1,
aura_interrupt_flags: 0x18,
..Default::default()
};
assert!(cast_moving_refusal(mf::FORWARD, 0, Some(&sit_still)));
// Auto-repeat (AttributesEx2 & 0x20) is unconditionally exempt — Auto Shot fires on
// the run whatever its columns say.
let auto_shot = SpellDisplay {
interrupt_flags: 0x1,
attributes_ex2: 0x20,
aura_interrupt_flags: 0x18,
..Default::default()
};
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&auto_shot)));
// No record: nothing to read, the press passes (the server stays the net).
assert!(!cast_moving_refusal(mf::FORWARD, 1500, None));
}
}
#[cfg(test)]
mod cc_refusal_tests {
use super::*;
const STUNNED: u32 = 0x0004_0000;
const SILENCED: u32 = 0x0000_2000;
const PACIFIED: u32 = 0x0002_0000;
const CONFUSED: u32 = 0x0040_0000;
const FLEEING: u32 = 0x0080_0000;
fn spell(prevention_type: u32) -> SpellDisplay {
SpellDisplay {
prevention_type,
..Default::default()
}
}
/// A scan that finds no aura at all — `exempt: false, mechanic: 0`, so every arm falls to its
/// OWN reason. This is the ordinary case: the exemption only ever fires for a caster wearing
/// an immunity.
fn no_auras() -> impl FnMut(&[u32]) -> benilla_formats::CcExemption {
|_: &[u32]| benilla_formats::CcExemption::default()
}
/// The reason alone, dropping the mechanic — most of these tests are about which arm fires.
fn reason_of(v: Option<(u8, Option<u32>)>) -> Option<u8> {
v.map(|(r, _)| r)
}
/// A live caster, nobody else at the reins, no auras.
fn cc(flags: u32, d: &SpellDisplay) -> Option<u8> {
cast_cc_refusal(flags, Some(100), false, Some(d), &mut no_auras()).map(|(r, _)| r)
}
/// **The arms are not symmetric** (decision 1904, widened by 1925): stun, fear, charm and
/// confuse refuse EVERY spell; silence and pacify only the rows whose `PreventionType` names
/// them. Reading `UNIT_FLAG_SILENCED` as "no casting at all" — which our own preflight banner
/// used to say — is the mistake this pins.
#[test]
fn crowd_control_refuses_by_prevention_type_except_the_unconditional_arms() {
// Fireball-shaped (silence-preventable), Heroic-Strike-shaped (pacify-preventable), and
// the auto-attack's neither — the three real values, pinned in `spell_catalog`.
let cast = spell(1);
let melee = spell(2);
let neither = spell(0);
// The four arms with NO per-spell gate: every row refuses.
for (flags, reason) in [(STUNNED, 0x64), (FLEEING, 0x1e), (CONFUSED, 0x16)] {
for d in [&cast, &melee, &neither] {
assert_eq!(cc(flags, d), Some(reason), "flags {flags:#x}");
}
}
// …and charm, which is not a flag at all but "somebody else holds the reins".
assert_eq!(
reason_of(cast_cc_refusal(
0,
Some(100),
true,
Some(&neither),
&mut no_auras()
)),
Some(0x14)
);
// SILENCED takes the casts and leaves the rest alone.
assert_eq!(cc(SILENCED, &cast), Some(0x60));
assert_eq!(cc(SILENCED, &melee), None);
assert_eq!(cc(SILENCED, &neither), None);
// PACIFIED is the mirror.
assert_eq!(cc(PACIFIED, &melee), Some(0x5a));
assert_eq!(cc(PACIFIED, &cast), None);
// Nothing up, nothing refused; and no record claims no prevention.
assert_eq!(cc(0, &cast), None);
assert_eq!(
reason_of(cast_cc_refusal(
SILENCED,
Some(100),
false,
None,
&mut no_auras()
)),
None
);
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
None,
&mut no_auras()
)),
Some(0x64),
"the stun needs no record"
);
}
/// **The order is the reference's, first match wins** — charm outranks the stun, the stun
/// outranks everything below it. When several hold at once the player sees exactly one line,
/// and which one is not arbitrary.
#[test]
fn the_arms_are_tried_in_the_references_order() {
let cast = spell(1);
// Charm is arm 1: it beats a simultaneous stun.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED | SILENCED,
Some(100),
true,
Some(&cast),
&mut no_auras()
)),
Some(0x14)
);
// Stun is arm 2: it beats silence, pacify, fear and confuse.
assert_eq!(
cc(STUNNED | SILENCED | PACIFIED | FLEEING | CONFUSED, &cast),
Some(0x64)
);
// Silence (3) beats fear (5) and confuse (6).
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &cast), Some(0x60));
// With the spell out of silence's reach, fear takes it before confuse.
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &spell(0)), Some(0x1e));
}
/// **What the exemption does to an arm** (decision 1946) — the three outcomes, at the arm.
#[test]
fn the_exemption_skips_an_arm_or_renames_its_refusal() {
let cast = spell(1);
let scan = |exempt: bool, mechanic: u32| {
move |_: &[u32]| benilla_formats::CcExemption { exempt, mechanic }
};
// No aura of the arm's type: the arm's OWN reason, as everywhere else.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(false, 0)
)),
Some(0x64)
);
// A blocking aura the cast does NOT counter: the refusal survives but is renamed to
// `0x8d` "Can't do that while %s", the mechanic naming the aura.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(false, 12)
)),
Some(0x8d)
);
// The cast grants immunity: the arm is SKIPPED and the cast goes out. This is the whole
// point — Ice Block cast while stunned.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(true, 0)
)),
None
);
// …and skipping one arm does not skip the ladder: a silenced-and-stunned caster whose
// spell counters only the stun still refuses on silence below it.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED | SILENCED,
Some(100),
false,
Some(&cast),
&mut |types: &[u32]| {
// Exempt from the stun arm `{12}` only; the silence arm `{27, 12, 60}` is not.
benilla_formats::CcExemption {
exempt: types == [12],
mechanic: 0,
}
}
)),
Some(0x60)
);
}
/// **The mechanic rides out with the refusal** (decision 1948) — the half that turns
/// "Can't do that while %s" into a sentence. It is the ONE client-local refusal that carries
/// an argument word, and it is `None` for every arm that fell to its own reason.
#[test]
fn the_renamed_refusal_carries_the_mechanic_and_the_others_carry_nothing() {
let cast = spell(1);
let scan = |exempt: bool, mechanic: u32| {
move |_: &[u32]| benilla_formats::CcExemption { exempt, mechanic }
};
// Rejected by a blocking aura: reason `0x8d` AND the mechanic that names it.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 12)),
Some((0x8d, Some(12)))
);
// No aura: the arm's own reason, and NO argument — the message has no `%s` to fill.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 0)),
Some((0x64, None))
);
// Exempt: nothing at all.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(true, 0)),
None
);
}
/// **A dead caster skips all six** (`0x60980d`'s `jle` on `UNIT_FIELD_HEALTH`) — a corpse is
/// refused by an earlier rung, and reporting a stun over it would be the wrong line.
#[test]
fn a_dead_caster_takes_no_crowd_control_refusal() {
let cast = spell(1);
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(0),
false,
Some(&cast),
&mut no_auras()
)),
None
);
assert_eq!(
reason_of(cast_cc_refusal(
0,
Some(0),
true,
Some(&cast),
&mut no_auras()
)),
None
);
// Alive again, and the arm is back.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(1),
false,
Some(&cast),
&mut no_auras()
)),
Some(0x64)
);
// No health field at all is not death — the descriptor simply has not landed.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
None,
false,
Some(&cast),
&mut no_auras()
)),
Some(0x64)
);
}
}
+5 -5
View File
@@ -73,7 +73,7 @@ pub(super) fn world_right_click_payload(
pub(super) fn select_on_plate_click(
mut plate: ResMut<crate::vplates::PlateClicks>,
press: Res<PressPick>,
ground: Res<crate::ui_action::SpellTargeting>,
ground: Res<crate::spell::SpellTargeting>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
self_q: Query<(&Guid, Has<Engaged>), With<SelfPlayer>>,
@@ -140,7 +140,7 @@ pub(super) fn select_on_click(
self_q: Query<(&Guid, Has<Engaged>), With<SelfPlayer>>,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
ground: Res<crate::ui_action::SpellTargeting>,
ground: Res<crate::spell::SpellTargeting>,
click_cfg: Res<ClickConfig>,
// The clicked unit's descriptor — `0x493540`'s `IsSelectable` gate reads it inside
// [`scan::commit`]. Read live rather than latched with the press pick: the reference resolves
@@ -681,7 +681,7 @@ pub(super) fn act_on_right_click(
.spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id));
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
if crate::spell::validator::cast_mounted_refusal(self_mounted, def) {
debug!("right-click corpse insignia: refused locally — mounted (0x39)");
cast_errors.push_local(spell_id, 0x39);
} else {
@@ -841,7 +841,7 @@ pub(super) fn act_on_right_click(
.spells
.as_ref()
.and_then(|s| s.catalog.get(spell_id));
if crate::ui_action::cast_mounted_refusal(self_mounted, def) {
if crate::spell::validator::cast_mounted_refusal(self_mounted, def) {
debug!("right-click skin: refused locally — mounted (0x39)");
cast_errors.push_local(spell_id, 0x39);
} else {
@@ -2178,7 +2178,7 @@ mod tests {
world.init_resource::<crate::spell::QueuedMeleeSpell>();
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_script::CursorPayloadHeld>();
world.init_resource::<crate::ui_action::SpellTargeting>();
world.init_resource::<crate::spell::SpellTargeting>();
world.init_resource::<ClickConfig>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
+4 -4
View File
@@ -450,11 +450,11 @@ impl Plugin for TargetPlugin {
// The right button's down-edge cancel first (the ref's OnMouseDown hook,
// 0792): the frame the press lands, the cursor drive below already reads
// the mode cleared and the classifier's verdict stands.
crate::ui_action::targeting::cancel_targeting_on_right_press,
crate::spell::targeting::cancel_targeting_on_right_press,
// The ground-targeting pre-empt (decision 0792): overwrites the classifier's
// verdict while the targeting cursor is up — the ref's dispatcher runs this
// branch before the object classifier; last-writer-wins reads the same.
crate::ui_action::targeting::drive_targeting_cursor,
crate::spell::targeting::drive_targeting_cursor,
// The AoE reticle reads that verdict (`WorldCursor.unable` IS the frame's
// range state — the ref's one CheckGroundPointInRange caller feeds both).
reticle::update_reticle,
@@ -467,8 +467,8 @@ impl Plugin for TargetPlugin {
// world legs are siblings, not a fallback chain — the pending spell's word
// decides which of them a click can even feed (decision 0939), so their order
// relative to each other never matters.
crate::ui_action::targeting::commit_ground_cast_on_click,
crate::ui_action::targeting::commit_object_cast_on_click,
crate::spell::targeting::commit_ground_cast_on_click,
crate::spell::targeting::commit_object_cast_on_click,
click::act_on_right_click,
click::clear_target_requests,
// The two unit-token drains, grouped as one element (the outer chain is at
+3 -2
View File
@@ -35,8 +35,9 @@
use bevy::prelude::*;
use crate::net::{ObjectStore, SelfPlayer};
use crate::spell::{ground_cast_radius, SpellTargeting, TargetingWants};
use crate::target::{PickOcclusion, WorldCursor};
use crate::ui_action::{ground_cast_radius, SpellTargeting, Spells, TargetingWants};
use crate::ui_action::Spells;
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::particles::buffer::EffectVertex;
use benilla_world::view::WorldCamera;
@@ -211,7 +212,7 @@ pub(super) fn push_reticle(
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_action::CastCommit;
use crate::spell::CastCommit;
use avian3d::prelude::Collider;
use benilla_world::collision::GroundDecalSurface;
use bevy::ecs::system::RunSystemOnce;
+4 -31
View File
@@ -1,7 +1,7 @@
//! **Outward** — the two action-bar drains, and the law that decides what a click *does*.
//!
//! - **Use** ([`drain_action_uses`]): a queued `UseAction(n)` becomes wire. A SPELL action goes
//! through the one cast-send path ([`super::cast_send::send_spell_cast`]); the auto-attack action
//! through the one cast-send path ([`crate::spell::CastLadder::send`]); the auto-attack action
//! (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.
@@ -23,8 +23,9 @@ use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands};
use super::cast_send::{CastCommit, CastLadder};
use super::{attack_actor_refusal, cast_target, PlayerActions, UiErrorKeys, SPELL_ATTACK};
use crate::spell::{cast_target, CastCommit, CastLadder};
use super::{attack_actor_refusal, PlayerActions, UiErrorKeys, SPELL_ATTACK};
/// What clicking an ITEM action does, and to which copy — [`item_action_route`]'s verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -135,34 +136,6 @@ pub(super) fn attack_target_binding(
}
}
/// **The `modalNextSpell` chain** — `HandleCastResult 0x6e7330`'s tail (`0x6e7447`–`0x6e74aa`),
/// the client casting a spell at itself with no user input. `cast_result` decides *whether*
/// (the column read, the in-flight test, the already-running test — all of it is the packet
/// handler's, so it stays there); this only carries the decision to the one send path.
///
/// The cast goes out at the **null target guid** — `0x6e74a6 push ebx; push ebx` with `ebx = 0`
/// — so the chained Auto Shot binds through the ordinary target walk (`ArmCast 0x6e5250`:
/// main-hand item bit, then the explicit guid, then the current selection), which is what
/// [`cast_target::CastTargeting::context`] hands the ladder when no guid is passed.
///
/// And it takes **every rung**: the reference chains through `0x6e5a90` → `TryCast 0x6e4b60`, the
/// same entry a button press uses, so a chained Auto Shot is range-checked, form-checked and
/// GCD-checked exactly like a pressed one, and refuses with the same red line.
pub(super) fn drain_chain_casts(
mut queue: ResMut<crate::spell::ChainCasts>,
targeting: cast_target::CastTargeting,
mut ladder: CastLadder,
) {
if queue.0.is_empty() {
return;
}
let ctx = targeting.context();
for spell_id in std::mem::take(&mut queue.0) {
debug!("ui_action: modalNextSpell chain casts {spell_id}");
ladder.send(spell_id, &ctx, CastCommit::Spell);
}
}
/// **The world right-click's GameObject opener**, run through the one cast path (decision 2199) —
/// the seam [`crate::ui_action::GoOpenerCasts`] exists for.
///
@@ -30,7 +30,7 @@ use bevy::prelude::*;
use benilla_ui::script::{CursorPayload, UiScript};
use super::cast_send::{CastCommit, TargetedBind};
use crate::spell::{CastCommit, TargetedBind};
/// `0x6ea1e0` — is this unit a pet *I* can feed? All three gates, in the reference's order. Any
/// miss is a silent refusal (see the module docs), so this returns a plain bool rather than a
@@ -74,7 +74,7 @@ pub(crate) fn drop_item_on_unit(
learned: Res<super::LearnedAbilities>,
press: Res<crate::target::PressPick>,
mut clicks: MessageReader<benilla_world::interact::WorldClick>,
mut ladder: super::CastLadder,
mut ladder: crate::spell::CastLadder,
) {
let Some(mut script) = script else {
clicks.clear();
+7 -60
View File
@@ -12,15 +12,16 @@
//! checked/flash — the per-frame half, fed after the identity half so a fresh slot's first state
//! push lands the same frame.
//! - **Outward — use** ([`drain::drain_action_uses`]): a queued `UseAction(n)` becomes wire — a
//! spell through the one cast-send path ([`cast_send`]), the auto-attack through
//! spell through the one cast-send path ([`crate::spell::CastLadder`]), the auto-attack through
//! `CMSG_ATTACKSWING`, an item through the two-stage equip-vs-use law ([`drain::item_action_route`],
//! decision 0666).
//! - **Outward — set** ([`drain::drain_action_sets`]): the cursor seam's `PickupAction`/
//! `PlaceAction` mutations become `CMSG_SET_ACTION_BUTTON` sends, one per queued entry.
//!
//! The supporting law sits alongside: [`cast_target`] (which unit a cast binds), [`cast_fail`] +
//! [`errors`] (the red error line's two layers), [`usable`] (the castability walk the stance bar
//! shares), [`weapon_icon`] (the auto-attack's borrowed weapon icon).
//! The supporting law sits alongside: [`cast_fail`] + [`errors`] (the red error line's two
//! layers) and [`weapon_icon`] (the auto-attack's borrowed weapon icon). The cast ladder itself —
//! the target bind, the validator's rungs, the usable walk, the targeting cursor — is the spell's
//! ([`crate::spell`], decision 2330); this module is one of its callers.
use std::collections::{BTreeSet, HashMap};
@@ -34,8 +35,6 @@ use crate::ui_unit::UnitFeed;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
mod cast_fail;
mod cast_send;
pub(crate) mod cast_target;
mod drain;
#[cfg(test)]
mod drain_tests;
@@ -47,7 +46,6 @@ mod feed_tests;
mod net;
mod ranks;
mod state;
pub(crate) mod targeting;
pub(crate) mod toggle;
mod weapon_icon;
@@ -61,43 +59,14 @@ mod weapon_icon;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CooldownEvents;
// The one cast path, and the only way in: every caster surface takes [`CastLadder`] as a single
// SystemParam and commits through [`CastCommit`] — `send_spell_cast` itself is private to
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
/// `autoSelfCast`'s change callback (decision 2303): a flag.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut auto: ResMut<AutoSelfCast>) {
if ev.is("autoSelfCast") {
auto.0 = ev.flag();
}
}
pub(crate) use errors::{
attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
show_messages, ui_error_text, CastErrors, CastFail, Caster, FillArg, MessageSink, MountErrors,
PetTameFailures, Shown, UiError, UiErrorKeys, UiErrorTexts,
};
// `pub(crate)`: the requirement validator's mounted block is ONE gate in the reference
// (`0x6094f0` @ `0x609c6c`) sitting under the ONE cast entry `TryCast 0x6e4b60` — but benilla still
// has cast sends outside [`CastLadder`]: the world click's skin cast, the corpse insignia cast and
// the GameObject opener (decisions 0552/0914). Each of them reaches TryCast in the reference
// (`0x5f05e0 → 0x6e5a90 → 0x6e4b60`; `0x5f35c0 → 0x6e5a90 → 0x6e4b60`), so each of them must ask
// this question — the same reason `reagent_totem_refusal` above is already exported. Exporting the
// predicate is the honest interim; the ladder refactor that would retire it is 0914's, not 1851's.
pub(crate) use state::cast_mounted_refusal;
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
// `TargetingWants` travels with it because the chain also holds a *seam-specific* consumer — the
// ground reticle, which draws for the location word alone (decision 0943).
pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants};
// `pub(crate)`: the spellbook shows the same borrowed weapon icon its bar buttons do, pre-resolved
// once per page (decisions 0230/0231).
pub(crate) use weapon_icon::{melee_auto_attack_icon, ranged_weapon_icon};
// `pub(crate)`: the stance bar's `isCastable` IS this walk (`GetShapeshiftFormInfo`'s fourth
// return runs `0x6e3d60`, wow-re shapeshift-bar-api.md) — `crate::ui_shapeshift` calls it with
// the same ctx shape the state feed builds.
pub(crate) mod usable;
/// The auto-attack pseudo-spell (`Attack`, every character's slot-1 default): not a cast — it
/// toggles melee via the attack-swing pair. The USE path (`CMSG_ATTACKSWING`) keys on this id; the
@@ -139,8 +108,8 @@ pub(crate) struct PlayerActions {
/// **The world right-click's GameObject-opener queue** — the lock chain's resolved action, carried
/// one frame to the one cast path (decision 2199).
///
/// It exists for exactly the reason [`crate::spell::ChainCasts`] does, and no other: the right-click system
/// ([`crate::target::click::act_on_right_click`]) and [`CastLadder`] want the same half-dozen
/// It exists because the right-click system ([`crate::target::click::act_on_right_click`]) and
/// [`crate::spell::CastLadder`] want the same half-dozen
/// resources, so the click cannot call the ladder in place — a resource reachable twice from one
/// system is a `B0002` panic on the first live frame. A one-frame queue is the seam, and it keeps
/// the rule that **nothing sends a cast except the ladder**.
@@ -345,7 +314,6 @@ pub(crate) struct UiActionPlugin;
impl Plugin for UiActionPlugin {
fn build(&self, app: &mut App) {
net::register(app);
app.add_observer(on_cvar);
app.init_resource::<PlayerActions>()
.init_resource::<LearnedAbilities>()
.init_resource::<CastErrors>()
@@ -354,9 +322,6 @@ impl Plugin for UiActionPlugin {
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
.init_resource::<GoOpenerCasts>()
.init_resource::<cast_target::AutoSelfCast>()
.init_resource::<targeting::SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
.add_systems(Startup, load_spells.after(AssetSet::Open))
.add_systems(Startup, load_spell_mechanics.after(AssetSet::Open))
.add_systems(
@@ -385,11 +350,6 @@ impl Plugin for UiActionPlugin {
// The T binding (0997): the attack arm's twin door, after the dispatch wrote
// this frame's fires.
drain::attack_target_binding.after(UiInput),
// The `modalNextSpell` chain (1597): a hunter shot's CAST_RESULT queues
// Auto Shot, and it goes out through the same ladder every other caster
// takes. After the input pass like the other drains — the queue is filled by
// the net drain, which runs earlier in the frame.
drain::drain_chain_casts.after(UiInput),
// The world right-click's opener (2199), beside the chain cast and for the
// same reason: the click resolved it, the ladder sends it. After the input
// pass like the other drains — the queue is filled by the target chain,
@@ -401,19 +361,6 @@ impl Plugin for UiActionPlugin {
track_learned_abilities
.in_set(UnitFeed)
.after(feed::feed_actions),
// The targeting mode's ESC-chain halves (decision 0792): the state push
// rides the feeds (before the input pass runs `ToggleGameMenu`), the
// trigger drain follows it — same frame, so an ESC's cancel lands before
// the next frame's cursor drive reads the mode. The cursor pre-empt, the
// right-press cancel, and the click commit register in the TARGET chain
// (ordering against the classifier and the select click is theirs to own).
targeting::feed_targeting_to_vm.in_set(UnitFeed),
targeting::drain_stop_targeting.after(UiInput),
// The item half's commit (decision 0923) — the bag / paper-doll click seam's
// `0x495d60`. A UI drain like the others: after the input pass, so a click
// this frame binds this frame. It is deliberately NOT in the target chain —
// the clicks it consumes never reach the world.
targeting::commit_item_cast_on_pick.after(UiInput),
),
);
}
+7 -841
View File
@@ -19,7 +19,7 @@
//! power-cost verdict, `IsCurrentAction` keys on the engaged attack GUID / the in-flight cast id,
//! `IsAutoRepeatAction` on the `0xceac30` key, and the range test is squared distance against the
//! byte-verified `GetMinMaxRange 0x6e3480` (its constants transcribed below). The usable pair
//! itself is the full `IsSpellUsableNow 0x6e3d60` gate walk — [`super::usable`], the 2026-07-10
//! itself is the full `IsSpellUsableNow 0x6e3d60` gate walk — [`crate::spell::usable`], the 2026-07-10
//! §2a fold-back: reagents, forms, stealth, aura states (the Execute-family target dependence),
//! the works.
@@ -29,7 +29,6 @@ use std::time::Instant;
use bevy::prelude::*;
use benilla_formats::{SpellDisplay, SpellRange};
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use benilla_ui::script::{ActionState, UiScript};
@@ -39,7 +38,8 @@ use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::spell::Cooldowns;
use crate::target::Selection;
use super::{usable, PlayerActions, Spells};
use super::{PlayerActions, Spells};
use crate::spell::usable;
use crate::spell::AutoRepeatActive;
/// The feed's memory: what was last pushed, and the edge detectors.
@@ -53,290 +53,6 @@ pub(super) struct StateMemory {
last_cd_trace: Option<Instant>,
}
/// The client's cast-fail reasons for the two range refusals ("Out of range." / "Target too
/// close" in `super::cast_error_text`'s table) — what `CanTargetUnit 0x6e4440` emits when
/// `IsTargetInRange 0x6e47b0` fails on its max² / min² compare.
pub(super) const ERR_OUT_OF_RANGE: u8 = 0x59;
pub(super) const ERR_TOO_CLOSE: u8 = 0x76;
/// The **pre-send** range refusal — the client's `TryCast` ladder runs `CanTargetUnit 0x6e4440`
/// → `IsTargetInRange 0x6e47b0` BEFORE `ArmCast`/`SendCast` (`wave-cast.md`, byte-verified), so
/// an out-of-range or too-close press fails locally and the commit tail — the ranged sheath
/// snap `0x6e5930` included — never runs. This is why a too-close Throw/Auto Shot must NOT draw
/// the ranged weapon. Squared 3D distance against [`benilla_formats::min_max_range`]'s {min, max}: beyond max² →
/// [`ERR_OUT_OF_RANGE`], inside a nonzero min² → [`ERR_TOO_CLOSE`]. Untestable inputs (no range
/// row, unknown distance) pass — the server still judges the cast.
pub(super) fn cast_range_refusal(
spell: &SpellDisplay,
row: Option<&SpellRange>,
self_reach: f32,
target_reach: Option<f32>,
dist_sq: Option<f32>,
) -> Option<u8> {
let (min, max) = benilla_formats::min_max_range(spell, row, self_reach, target_reach)?;
let d2 = dist_sq?;
if d2 > max * max {
return Some(ERR_OUT_OF_RANGE);
}
if min > 0.0 && d2 < min * min {
return Some(ERR_TOO_CLOSE);
}
None
}
/// `PreventionType` values (`Spell.dbc` column 165): which crowd-control flag can refuse this
/// spell locally. `0` = neither.
const PREVENTION_SILENCE: u32 = 1;
const PREVENTION_PACIFY: u32 = 2;
/// The **crowd-control leg** of the same requirement validator `0x6094f0`, sitting **above** its
/// mounted block (`0x609c6c`) — so a stunned mounted caster is told about the stun (decision 1904;
/// wow-re `equipped-item-and-cc-cast-gates.md` §2.1, byte-verified). It refuses before any packet,
/// which is why it must be local.
///
/// **Six arms, in the reference's order, first match wins** — 1863's fold-back recorded four:
///
/// | # | arm | gate | reason |
/// |---|---|---|---|
/// | 1 | CHARMED | `UNIT_FIELD_CHARMEDBY != 0` and the charmer is not us | `0x14` |
/// | 2 | STUNNED | bit 18 — **no per-spell gate, every spell** | `0x64` |
/// | 3 | SILENCED | bit 13 **and** `PreventionType == 1` | `0x60` |
/// | 4 | PACIFIED | bit 17 **and** `PreventionType == 2` | `0x5a` |
/// | 5 | FLEEING | bit 23 | `0x1e` |
/// | 6 | CONFUSED | bit 22 | `0x16` |
///
/// The asymmetry is the finding: STUNNED, FLEEING, CHARMED and CONFUSED carry no per-spell gate at
/// all, while SILENCED and PACIFIED run only where the spell's own `PreventionType` names them —
/// so a silence stops casts and leaves melee abilities alone, and a pacify does the reverse.
/// Reading `UNIT_FLAG_SILENCED` as "no casting" is the mistake this replaces.
///
/// **A dead caster skips all six** (`0x60980d`, a `jle` on `UNIT_FIELD_HEALTH`).
///
/// This ladder is **TryCast-only** — one caller, no address-takes — so a button does **not** grey
/// while stunned, silenced or pacified: it refuses on the press. (Fear, confuse and charm *are*
/// greyed, by a second copy of the same helpers inside `0x6e3d60`; that copy is not built here.)
///
/// Its byte-shape was nearly missed by a census twice: SILENCED's read is `f6 c4 20 test ah,0x20`,
/// a sub-register byte-lane form with no dword immediate, invisible to an immediate scan.
///
/// **The exemption scan is built** (decision 1946, closing 1925's deferral): each arm first asks
/// whether any of the caster's own auras grants immunity to what is blocking it — a scan of
/// `UNIT_FIELD_AURA[0..47]`'s raw spell ids for an aura of the arm's own type, then
/// [`benilla_formats::grants_immunity`] on each match. A hit **lifts** the refusal; a rejection
/// names the blocking mechanic and turns the arm's own reason into `0x8d`. Each arm scans for a
/// different set of aura types — charm `{6, 177, 2}`, stun `{12}`, silence `{27, 12, 60}`, pacify
/// `{25, 12, 60}`, fear `{7}`, confuse `{5}` — and those sets are the reference's, not a family
/// resemblance.
pub(crate) fn cast_cc_refusal(
unit_flags: u32,
health: Option<u32>,
charmed_by_other: bool,
spell: Option<&SpellDisplay>,
exempt: &mut impl FnMut(&[u32]) -> benilla_formats::CcExemption,
) -> Option<(u8, Option<u32>)> {
use crate::player::UNIT_FLAG_STUNNED;
/// `UNIT_FIELD_FLAGS` bits 13/17/22/23 (vmangos `UnitDefines.h`).
const UNIT_FLAG_SILENCED: u32 = 0x0000_2000;
const UNIT_FLAG_PACIFIED: u32 = 0x0002_0000;
const UNIT_FLAG_CONFUSED: u32 = 0x0040_0000;
const UNIT_FLAG_FLEEING: u32 = 0x0080_0000;
// The dead caster's skip (`0x60980d`): a corpse is refused by an earlier rung, not this one.
if health == Some(0) {
return None;
}
let prevention = spell.map_or(0, |d| d.prevention_type);
// One arm: ask its exemption scan first, and let its answer pick between silence and one of
// two messages. `exempt` returning `exempt: true` means one of the caster's own auras grants
// immunity to whatever is blocking — the arm is SKIPPED and the cast proceeds. Otherwise the
// refusal is `0x8d` "Can't do that while %s" when the scan named a mechanic, and the arm's own
// reason when it did not (decision 1946).
let mut arm = |aura_types: &[u32], own_reason: u8| -> Option<(u8, Option<u32>)> {
let scan = exempt(aura_types);
if scan.exempt {
return None;
}
Some(if scan.mechanic != 0 {
// The mechanic rides along as the message's `%s` — the one client-LOCAL refusal that
// carries an argument word (decision 1948).
(REASON_PREVENTED_BY_MECHANIC, Some(scan.mechanic))
} else {
(own_reason, None)
})
};
if charmed_by_other {
if let Some(r) = arm(&[6, 177, 2], 0x14) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_STUNNED != 0 {
if let Some(r) = arm(&[12], 0x64) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_SILENCED != 0 && prevention == PREVENTION_SILENCE {
if let Some(r) = arm(&[27, 12, 60], 0x60) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_PACIFIED != 0 && prevention == PREVENTION_PACIFY {
if let Some(r) = arm(&[25, 12, 60], 0x5a) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_FLEEING != 0 {
if let Some(r) = arm(&[7], 0x1e) {
return Some(r);
}
}
if unit_flags & UNIT_FLAG_CONFUSED != 0 {
if let Some(r) = arm(&[5], 0x16) {
return Some(r);
}
}
None
}
/// `SPELL_FAILED_PREVENTED_BY_MECHANIC` — "Can't do that while %s", `%s` being the blocking aura's
/// `SpellMechanic.dbc` name. Every crowd-control arm carries this **as well as** its own reason,
/// and which one appears is decided by whether the exemption scan named a mechanic.
const REASON_PREVENTED_BY_MECHANIC: u8 = 0x8d;
/// The **pre-send** mounted refusal (decision 0481) — the requirement validator `0x6094f0`'s
/// mounted block (`0x609c6c`, wow-re `mounted-action-gate.md` §5): a live
/// `UNIT_FIELD_MOUNTDISPLAYID` refuses the cast with reason `0x39` ("You are mounted") unless
/// the spell carries Attributes bit 24 (`0x01000000`, castable-while-mounted — the exemption
/// test at `0x609c6f`, the exact vmangos `SPELL_ATTR_ALLOW_WHILE_MOUNTED` mirror). A spell
/// with no loaded record has no exemption to claim — the gate holds (the ref always has the
/// record; refusing without data errs toward the ref's visible behavior). The sibling
/// mount-REQUIRED gate (reason 0x53, `0x609c05`) is recorded but unbuilt — no 1.12 player spell
/// exercises it. It is **two-armed** like the water pair below, not the single `+0x5c & 0x40`
/// read this comment used to claim: arm A is `AuraInterruptFlags & 0x40` (the `cl` at
/// `0x609c05` is the untouched low byte of `ecx = [esi+0x58]`, loaded 0x104 bytes earlier for
/// the unsheathed leg), arm B is `ChannelInterruptFlags & 0x40` at `0x609c3a` — corrected by
/// the 1063 §5 in wow-re `mounted-action-gate.md`. Note the plain mounted gate above is NOT in
/// that family: it is single-armed on `Attributes`.
pub(crate) fn cast_mounted_refusal(mounted: bool, spell: Option<&SpellDisplay>) -> bool {
mounted && spell.is_none_or(|d| d.attributes & 0x0100_0000 == 0)
}
/// The interrupt-flag water pair — the two bits that say *which side of the surface this aura
/// can live on*: `0x80` cancels it on ENTERING water, `0x100` on LEAVING it (vmangos
/// `AURA_INTERRUPT_UNDER_WATER_CANCELS` / `AURA_INTERRUPT_ABOVE_WATER_CANCELS`, bits 7/8). The
/// requirement validator reads the same pair to refuse the *cast* whose aura could not survive
/// the caster's current side.
const INTERRUPT_UNDER_WATER: u32 = 0x80;
const INTERRUPT_ABOVE_WATER: u32 = 0x100;
/// `AttributesEx & (IS_CHANNELED 0x4 | IS_SELF_CHANNELED 0x40)` — the gate on **arm B** of every
/// leg in this family (byte-verified `0x609d6a`/`0x609db1`). A channeled spell's requirement
/// bits live in its CHANNEL column, so the validator reads that column too — but only for a
/// spell that actually channels, which is what keeps Summon Baby Shark 25849 (`Channel 0x100`,
/// `AttributesEx 0`) out of the gate.
const ATTR_EX_CHANNELED: u32 = 0x44;
/// `SPELL_FAILED_ONLY_ABOVEWATER` — "Cannot use while swimming".
pub(super) const ERR_ONLY_ABOVEWATER: u8 = 0x50;
/// `SPELL_FAILED_ONLY_UNDERWATER` — "Can only use while swimming".
pub(super) const ERR_ONLY_UNDERWATER: u8 = 0x58;
/// The **pre-send** water refusal (decisions 1056 + 1063) — the requirement validator
/// `0x6094f0`'s environment block `0x609d33–0x609de2`, byte-carved by the wow-re §5 trio
/// (`system/spell/scratch/water-cast-gate.md`). It sits after the mounted/posture/day/night
/// legs and before the moving gate (`0x609de3`), which is where the ladder runs it — so a druid
/// standing on land is refused **before** the form gate `0x612480` ever evaluates.
///
/// One gate, two faces, and each face has **two arms** reading the same bit in two columns:
///
/// | reason | bit | arm A — `AuraInterruptFlags` (+0x58) | arm B — `ChannelInterruptFlags` (+0x5c), if channeled |
/// |---|---|---|---|
/// | [`ERR_ONLY_UNDERWATER`] `0x58` | `0x100` | `0x609d36` / swim `0x609d46` | `0x609d6f` / swim `0x609d7b` |
/// | [`ERR_ONLY_ABOVEWATER`] `0x50` | `0x80` | `0x609da4` / swim `0x609dac` | `0x609db5` / swim `0x609dc2` |
///
/// The swimming state is `[[caster+0x118]+0x40] & 0x200000` — the same wire-layout movement word
/// the moving gate reads, four times over, twice in each face.
///
/// **Arm B is not dead code, and leaving it out is visibly wrong**: Fishing rank 1 (7620)
/// carries `AuraInterruptFlags 0x80`, but ranks 2–4 (7731/7732/18248) carry **zero** and reach
/// the gate only through their `ChannelInterruptFlags 0x3cac`. Arm A alone would refuse rank 1
/// mid-swim and let its own upgrades through.
///
/// What the two bits actually select in the 5875 data: `0x80` is 245 rows — every mount, Travel
/// Form, the campfires, all of Food/Drink (`0x40080`), Fishing — and `0x100` is exactly five:
/// Aquatic Form 1066, the Lava/Slime swim auras 16455/16456, Master Angler 24346/24347.
///
/// **No exemption skips this block** (every jump into the run was enumerated) — unlike the
/// mounted gate's `Attributes` bit 24. And the gate must be LOCAL: vmangos's `CheckCast` gates
/// water only on `SPELL_AURA_MOUNTED` (`Spell.cpp:6379`), so it happily grants a druid aquatic
/// form on dry cobblestone (ledger B176, with the screenshot to prove it). An uncataloged spell
/// passes, like every other data-driven rung on this ladder.
///
/// The legs are evaluated `0x58` before `0x50`, the binary's own order; they are mutually
/// exclusive on the swim bit, so the order is fidelity, not behaviour.
pub(super) fn cast_water_refusal(move_flags_word: u32, spell: Option<&SpellDisplay>) -> Option<u8> {
let d = spell?;
let swimming = move_flags_word & crate::creature_anim::move_flags::SWIMMING != 0;
// Arm A always; arm B only for a spell that actually channels.
let requires = |bit: u32| {
d.aura_interrupt_flags & bit != 0
|| (d.attributes_ex & ATTR_EX_CHANNELED != 0 && d.channel_interrupt_flags & bit != 0)
};
if !swimming && requires(INTERRUPT_ABOVE_WATER) {
return Some(ERR_ONLY_UNDERWATER);
}
if swimming && requires(INTERRUPT_UNDER_WATER) {
return Some(ERR_ONLY_ABOVEWATER);
}
None
}
/// The AuraInterruptFlags-space MOVING|TURNING pair (`0x18`) — the moving gate's
/// "movement would matter anyway" arms test it on BOTH `AuraInterruptFlags` (+0x58) and
/// `ChannelInterruptFlags` (+0x5c), byte-verified at `0x609e0e`/`0x609e1c`.
const AURA_INTERRUPT_MOVING_TURNING: u32 = 0x18;
/// The **pre-send** moving refusal (decision 0862) — the requirement validator `0x6094f0`'s
/// moving block (`0x609de3–0x609e48`; the sole client-local emitter of reason `0x2e` "Can't do
/// that while moving". wow-re `moving-cast-gate.md`, §5 byte-verified): a press while the
/// caster's live CMovement flags carry any of {forward, backward, strafe L/R, JUMPING} refuses
/// locally — no packet, no cast bar, no GCD. Without it, vmangos *accepts* the cast (its
/// CheckCast moving-reject covers only autorepeat/sit-still spells, `Spell.cpp:5432`) and then
/// `Spell::update`'s 0.5-yd movement interrupt kills it — the start-then-cancel grief this gate
/// exists to prevent. The full reject condition, gate for gate:
///
/// - **entry**: `InterruptFlags & 0x1` (movement-interruptible — instants without it pass);
/// - **the movement word**: the WIRE `MovementFlags` layout (`[unit+0x9a8]+0x40`), mask
/// `0x200f` = forward|backward|strafe + JUMPING — turning and pitch are outside it, and so is
/// FALLINGFAR (`0x4000`; the client has NO falling/Stuck exemption — that's vmangos-only);
/// - **exemption**: an auto-repeat spell (`AttributesEx2 & 0x20` — Auto Shot, Shoot) never
/// refuses, whatever else it carries;
/// - **would movement matter**: a nonzero resolved cast time ([`super::Spells::cast_time_ms`]),
/// OR the [`AURA_INTERRUPT_MOVING_TURNING`] bits on the aura/channel interrupt columns — the
/// OR-arms are how a zero-cast-time *channel* is still refused at initiation.
///
/// An uncataloged spell passes (every record-read above needs the row; the ladder's other
/// data-driven legs — cooldown, GCD, range — share the disposition, and the server's own
/// interrupt stays the safety net). In the validator's order this sits after the mounted block
/// and before the shapeshift-form leg (`0x609e50`), which is where [`super::send_spell_cast`]
/// runs it.
pub(super) fn cast_moving_refusal(
move_flags_word: u32,
cast_time_ms: u32,
spell: Option<&SpellDisplay>,
) -> bool {
use crate::creature_anim::move_flags;
// The verified 0x200f: ANY_MOVE (0xf) | FALLING (0x2000), in our identical wire layout.
const MOVING_MASK: u32 = move_flags::ANY_MOVE | move_flags::FALLING;
let Some(d) = spell else { return false };
d.interrupt_flags & crate::spell::SPELL_INTERRUPT_MOVEMENT != 0
&& move_flags_word & MOVING_MASK != 0
&& !d.auto_repeat()
&& (cast_time_ms != 0
|| d.aura_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0
|| d.channel_interrupt_flags & AURA_INTERRUPT_MOVING_TURNING != 0)
}
/// What a slot *is* once the MACRO indirection is applied — the reference's slot→spell resolver
/// `0x4e5a50` plus the leg of the usable compute `0x4e5050` that reads its zero (decision 1636).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -402,7 +118,7 @@ pub(super) fn feed_action_state(
Res<crate::spell::PendingCast>,
Res<crate::spell::QueuedMeleeSpell>,
Res<crate::spell::ActiveChannel>,
Res<super::SpellTargeting>,
Res<crate::spell::SpellTargeting>,
Res<crate::ui_macro::MacroBoundSpells>,
Res<crate::spell::SpellModifiers>,
),
@@ -708,67 +424,7 @@ pub(super) fn feed_action_state(
#[cfg(test)]
mod tests {
use super::*;
fn spell_with_range(range_index: u32, attributes: u32) -> SpellDisplay {
SpellDisplay {
range_index,
attributes,
..Default::default()
}
}
/// The pre-send refusal (`IsTargetInRange 0x6e47b0`'s two compares over the resolved
/// bounds): Auto Shot's {8, 35} row + the unit reach pad — a point-blank target refuses
/// TOO_CLOSE, a distant one OUT_OF_RANGE, the sweet spot passes; untestable inputs pass.
#[test]
fn cast_range_refusal_follows_the_two_compares() {
let d = spell_with_range(114, 0);
let auto_shot = SpellRange {
min: 8.0,
max: 35.0,
flags: 0,
};
let reach = Some(1.5);
// Both bounds carry the bare reach pad (self 1.5 + target 1.5): min = 11, max = 38.
let refuse = |d2: f32| cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, Some(d2));
assert_eq!(refuse(3.0 * 3.0), Some(ERR_TOO_CLOSE));
assert_eq!(refuse(20.0 * 20.0), None);
assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE));
// The regression: a min-0 ranged row (Fireball/Shadow Bolt) must pass point-blank —
// its min never grows a reach pad — while the max compare still holds.
let fireball = SpellRange {
min: 0.0,
max: 35.0,
flags: 0,
};
let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), 1.5, reach, Some(d2));
assert_eq!(refuse(0.1), None);
assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE));
// A melee-family row has min 0 — never TOO_CLOSE, still OUT_OF_RANGE beyond reach.
let melee = SpellRange {
min: 0.0,
max: 5.0,
flags: 1,
};
let melee_spell = spell_with_range(2, 0);
assert_eq!(
cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(0.1)),
None
);
assert_eq!(
cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(15.0 * 15.0)),
Some(ERR_OUT_OF_RANGE)
);
// No row / no distance: nothing to test locally — pass (the server judges).
assert_eq!(cast_range_refusal(&d, None, 1.5, reach, Some(1.0)), None);
assert_eq!(
cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, None),
None
);
}
use benilla_formats::SpellDisplay;
/// A MACRO slot resolves through its bound spell for EVERY dynamic read (decision 0983) —
/// the `0x4e5a50` law — and the three values of `[rec+0x564]` split three ways at the usable
@@ -853,7 +509,7 @@ mod tests {
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::ui_action::SpellTargeting>()
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
.init_resource::<crate::net::Reputations>()
@@ -958,7 +614,7 @@ mod tests {
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::ui_action::SpellTargeting>()
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
.init_resource::<crate::net::Reputations>()
@@ -996,494 +652,4 @@ mod tests {
"in combat the food's on-use spell fails leg 8 and the button greys"
);
}
/// 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.
#[test]
fn cast_mounted_refusal_honors_the_bit24_exemption() {
let plain = SpellDisplay::default();
let exempt = SpellDisplay {
attributes: 0x0100_0000,
..Default::default()
};
assert!(cast_mounted_refusal(true, Some(&plain)));
assert!(!cast_mounted_refusal(true, Some(&exempt)));
assert!(!cast_mounted_refusal(false, Some(&plain)));
assert!(!cast_mounted_refusal(false, None));
assert!(cast_mounted_refusal(true, None), "no record, no exemption");
}
/// The water refusal (`0x609d33–0x609de2`) — both faces of the environment gate and both
/// arms of each face, on the real 5875 columns: Aquatic Form's `0x100` needs the SWIMMING
/// bit set, the mount/Travel-Form/food `0x80` needs it clear, Fishing's ranks reach it
/// through the CHANNEL column, and a spell carrying neither bit passes on both sides.
#[test]
fn cast_water_refusal_reads_both_sides_of_the_surface() {
use crate::creature_anim::move_flags as mf;
// Aquatic Form 1066: AuraInterruptFlags 0x100 — above-water cancels it, so the cast
// needs water. This is ledger B176: on land it must refuse, and it must not send.
let aquatic = SpellDisplay {
aura_interrupt_flags: 0x100,
..Default::default()
};
assert_eq!(
cast_water_refusal(0, Some(&aquatic)),
Some(ERR_ONLY_UNDERWATER)
);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&aquatic)), None);
// Travel Form 783 / every mount: 0x80 — entering water cancels it, so it refuses the
// other way round.
let travel = SpellDisplay {
aura_interrupt_flags: 0x80,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&travel)),
Some(ERR_ONLY_ABOVEWATER)
);
assert_eq!(cast_water_refusal(0, Some(&travel)), None);
// Food 433's shape (0x40080 = STANDING_CANCELS | UNDER_WATER_CANCELS) — the eating half
// of ledger B155 rides the same 0x80 arm.
let food = SpellDisplay {
aura_interrupt_flags: 0x4_0080,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&food)),
Some(ERR_ONLY_ABOVEWATER)
);
assert_eq!(cast_water_refusal(0, Some(&food)), None);
// ARM B (decision 1063): a CHANNELED spell's requirement bits live in its channel
// column. Fishing ranks 2–4's shape — AuraInterruptFlags zero, ChannelInterruptFlags
// 0x3cac (which carries 0x80), AttributesEx 0x21004004 (IS_CHANNELED).
let fishing_r2 = SpellDisplay {
aura_interrupt_flags: 0,
channel_interrupt_flags: 0x3cac,
attributes_ex: 0x2100_4004,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&fishing_r2)),
Some(ERR_ONLY_ABOVEWATER),
"arm A is empty here — only the channel column refuses it"
);
assert_eq!(cast_water_refusal(0, Some(&fishing_r2)), None);
// …and the `AttributesEx & 0x44` gate on arm B is load-bearing: Summon Baby Shark
// 25849's shape carries the channel bit but does not channel, so it is NOT gated.
let not_channeled = SpellDisplay {
channel_interrupt_flags: 0x100,
attributes_ex: 0,
..Default::default()
};
assert_eq!(cast_water_refusal(0, Some(&not_channeled)), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&not_channeled)), None);
// Cat Form 768 / Bear Form 5487 carry neither bit: usable on both sides, always.
let cat = SpellDisplay::default();
assert_eq!(cast_water_refusal(0, Some(&cat)), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&cat)), None);
// No record, nothing to read — the press passes, like every other data-driven rung.
assert_eq!(cast_water_refusal(0, None), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, None), None);
}
/// The water gate against the **real 5875 Spell.dbc** — the census the whole gate rests on.
/// The leg↔bit assignment is not a naming choice we could get backwards: exactly five rows
/// in the shipped data carry the water-REQUIRED bit, and Aquatic Form is one of them, while
/// the water-FORBIDDEN bit is a broad set led by the mounts, Travel Form and food/drink.
/// Skips without client data.
#[test]
fn the_water_bits_split_the_5875_data_the_way_the_gate_assumes() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
// Every spell the client would refuse OUT of water, by id.
let mut needs_water: Vec<u32> = catalog
.iter()
.filter(|(_, d)| d.aura_interrupt_flags & INTERRUPT_ABOVE_WATER != 0)
.map(|(id, _)| id)
.collect();
needs_water.sort_unstable();
assert_eq!(
needs_water,
vec![1066, 16455, 16456, 24346, 24347],
"the water-required set: Aquatic Form, the Lava/Slime swim auras, Master Angler"
);
// The other face: broad, and led by exactly the things you cannot do mid-swim.
let forbids_water = |id: u32| {
catalog
.get(id)
.is_some_and(|d| d.aura_interrupt_flags & INTERRUPT_UNDER_WATER != 0)
};
assert!(forbids_water(783), "Travel Form");
assert!(forbids_water(458), "Brown Horse");
assert!(forbids_water(433), "Food");
assert!(forbids_water(430), "Drink");
assert!(forbids_water(818), "Basic Campfire");
// ARM B's reason to exist, on the real rows (decision 1063). Fishing rank 1 carries the
// bit in the AURA column; its own upgrades carry NOTHING there and reach the gate only
// through the CHANNEL column. Arm A alone would refuse rank 1 mid-swim and let 2–4 fish.
let swim = crate::creature_anim::move_flags::SWIMMING;
assert!(forbids_water(7620), "Fishing rank 1 — arm A");
for rank in [7731, 7732, 18248] {
let d = catalog
.get(rank)
.unwrap_or_else(|| panic!("Fishing {rank} missing"));
assert_eq!(d.aura_interrupt_flags, 0, "Fishing {rank}: arm A is empty");
assert!(d.attributes_ex & ATTR_EX_CHANNELED != 0, "and it channels");
assert_eq!(
cast_water_refusal(swim, Some(d)),
Some(ERR_ONLY_ABOVEWATER),
"Fishing {rank} still refuses mid-swim, through the channel column"
);
assert_eq!(cast_water_refusal(0, Some(d)), None, "and fishes on shore");
}
// The forms that carry neither bit — usable on both sides, and the control that says the
// gate is reading a real per-spell column and not a class-wide accident.
for (id, name) in [(768, "Cat Form"), (5487, "Bear Form"), (2645, "Ghost Wolf")] {
let d = catalog.get(id).unwrap_or_else(|| panic!("{name} missing"));
assert_eq!(d.aura_interrupt_flags & 0x180, 0, "{name} is side-agnostic");
assert_eq!(cast_water_refusal(0, Some(d)), None, "{name} on land");
assert_eq!(cast_water_refusal(swim, Some(d)), None, "{name} swimming");
}
// And end to end, on the real rows: B176's exact press.
let aquatic = catalog.get(1066).expect("Aquatic Form");
assert_eq!(
cast_water_refusal(0, Some(aquatic)),
Some(ERR_ONLY_UNDERWATER),
"on land, Aquatic Form refuses — the B176 report"
);
assert_eq!(
cast_water_refusal(crate::creature_anim::move_flags::SWIMMING, Some(aquatic)),
None,
"swimming, it goes through"
);
}
/// The moving refusal (`0x609de3`) — every leg of the byte-verified condition: the
/// `InterruptFlags & 0x1` entry, the `0x200f` wire mask (turn/FALLINGFAR outside it,
/// JUMPING inside), the auto-repeat exemption, and the "would movement matter" arms (cast
/// time / aura / channel `0x18` bits).
#[test]
fn cast_moving_refusal_follows_the_validator_condition() {
use crate::creature_anim::move_flags as mf;
// Fireball's shape: ordinary timed cast (interrupt 0xf, nonzero cast time).
let timed = SpellDisplay {
interrupt_flags: 0xf,
..Default::default()
};
// Moving forward refuses; standing still doesn't.
assert!(cast_moving_refusal(mf::FORWARD, 1500, Some(&timed)));
assert!(!cast_moving_refusal(0, 1500, Some(&timed)));
// Strafe and JUMPING are in the mask; turn and FALLING_FAR are not (`0x200f`).
assert!(cast_moving_refusal(mf::STRAFE_LEFT, 1500, Some(&timed)));
assert!(cast_moving_refusal(mf::FALLING, 1500, Some(&timed)));
assert!(!cast_moving_refusal(mf::TURN_LEFT, 1500, Some(&timed)));
assert!(!cast_moving_refusal(mf::FALLING_FAR, 1500, Some(&timed)));
// An instant WITHOUT the movement interrupt bit passes (Fire Blast's shape)…
let instant = SpellDisplay {
interrupt_flags: 0xe,
..Default::default()
};
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&instant)));
// …and even WITH it, a zero cast time passes unless an 0x18 arm bites.
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&timed)));
// Arcane Missiles' shape: zero cast time, but the channel column's moving bits refuse
// at initiation (the OR-arm; 0x7c0c & 0x18 != 0).
let channel = SpellDisplay {
interrupt_flags: 0xf,
channel_interrupt_flags: 0x7c0c,
..Default::default()
};
assert!(cast_moving_refusal(mf::FORWARD, 0, Some(&channel)));
// The aura-column arm (food/drink sit-still bits).
let sit_still = SpellDisplay {
interrupt_flags: 0x1,
aura_interrupt_flags: 0x18,
..Default::default()
};
assert!(cast_moving_refusal(mf::FORWARD, 0, Some(&sit_still)));
// Auto-repeat (AttributesEx2 & 0x20) is unconditionally exempt — Auto Shot fires on
// the run whatever its columns say.
let auto_shot = SpellDisplay {
interrupt_flags: 0x1,
attributes_ex2: 0x20,
aura_interrupt_flags: 0x18,
..Default::default()
};
assert!(!cast_moving_refusal(mf::FORWARD, 0, Some(&auto_shot)));
// No record: nothing to read, the press passes (the server stays the net).
assert!(!cast_moving_refusal(mf::FORWARD, 1500, None));
}
}
#[cfg(test)]
mod cc_refusal_tests {
use super::*;
const STUNNED: u32 = 0x0004_0000;
const SILENCED: u32 = 0x0000_2000;
const PACIFIED: u32 = 0x0002_0000;
const CONFUSED: u32 = 0x0040_0000;
const FLEEING: u32 = 0x0080_0000;
fn spell(prevention_type: u32) -> SpellDisplay {
SpellDisplay {
prevention_type,
..Default::default()
}
}
/// A scan that finds no aura at all — `exempt: false, mechanic: 0`, so every arm falls to its
/// OWN reason. This is the ordinary case: the exemption only ever fires for a caster wearing
/// an immunity.
fn no_auras() -> impl FnMut(&[u32]) -> benilla_formats::CcExemption {
|_: &[u32]| benilla_formats::CcExemption::default()
}
/// The reason alone, dropping the mechanic — most of these tests are about which arm fires.
fn reason_of(v: Option<(u8, Option<u32>)>) -> Option<u8> {
v.map(|(r, _)| r)
}
/// A live caster, nobody else at the reins, no auras.
fn cc(flags: u32, d: &SpellDisplay) -> Option<u8> {
cast_cc_refusal(flags, Some(100), false, Some(d), &mut no_auras()).map(|(r, _)| r)
}
/// **The arms are not symmetric** (decision 1904, widened by 1925): stun, fear, charm and
/// confuse refuse EVERY spell; silence and pacify only the rows whose `PreventionType` names
/// them. Reading `UNIT_FLAG_SILENCED` as "no casting at all" — which our own preflight banner
/// used to say — is the mistake this pins.
#[test]
fn crowd_control_refuses_by_prevention_type_except_the_unconditional_arms() {
// Fireball-shaped (silence-preventable), Heroic-Strike-shaped (pacify-preventable), and
// the auto-attack's neither — the three real values, pinned in `spell_catalog`.
let cast = spell(1);
let melee = spell(2);
let neither = spell(0);
// The four arms with NO per-spell gate: every row refuses.
for (flags, reason) in [(STUNNED, 0x64), (FLEEING, 0x1e), (CONFUSED, 0x16)] {
for d in [&cast, &melee, &neither] {
assert_eq!(cc(flags, d), Some(reason), "flags {flags:#x}");
}
}
// …and charm, which is not a flag at all but "somebody else holds the reins".
assert_eq!(
reason_of(cast_cc_refusal(
0,
Some(100),
true,
Some(&neither),
&mut no_auras()
)),
Some(0x14)
);
// SILENCED takes the casts and leaves the rest alone.
assert_eq!(cc(SILENCED, &cast), Some(0x60));
assert_eq!(cc(SILENCED, &melee), None);
assert_eq!(cc(SILENCED, &neither), None);
// PACIFIED is the mirror.
assert_eq!(cc(PACIFIED, &melee), Some(0x5a));
assert_eq!(cc(PACIFIED, &cast), None);
// Nothing up, nothing refused; and no record claims no prevention.
assert_eq!(cc(0, &cast), None);
assert_eq!(
reason_of(cast_cc_refusal(
SILENCED,
Some(100),
false,
None,
&mut no_auras()
)),
None
);
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
None,
&mut no_auras()
)),
Some(0x64),
"the stun needs no record"
);
}
/// **The order is the reference's, first match wins** — charm outranks the stun, the stun
/// outranks everything below it. When several hold at once the player sees exactly one line,
/// and which one is not arbitrary.
#[test]
fn the_arms_are_tried_in_the_references_order() {
let cast = spell(1);
// Charm is arm 1: it beats a simultaneous stun.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED | SILENCED,
Some(100),
true,
Some(&cast),
&mut no_auras()
)),
Some(0x14)
);
// Stun is arm 2: it beats silence, pacify, fear and confuse.
assert_eq!(
cc(STUNNED | SILENCED | PACIFIED | FLEEING | CONFUSED, &cast),
Some(0x64)
);
// Silence (3) beats fear (5) and confuse (6).
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &cast), Some(0x60));
// With the spell out of silence's reach, fear takes it before confuse.
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &spell(0)), Some(0x1e));
}
/// **What the exemption does to an arm** (decision 1946) — the three outcomes, at the arm.
#[test]
fn the_exemption_skips_an_arm_or_renames_its_refusal() {
let cast = spell(1);
let scan = |exempt: bool, mechanic: u32| {
move |_: &[u32]| benilla_formats::CcExemption { exempt, mechanic }
};
// No aura of the arm's type: the arm's OWN reason, as everywhere else.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(false, 0)
)),
Some(0x64)
);
// A blocking aura the cast does NOT counter: the refusal survives but is renamed to
// `0x8d` "Can't do that while %s", the mechanic naming the aura.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(false, 12)
)),
Some(0x8d)
);
// The cast grants immunity: the arm is SKIPPED and the cast goes out. This is the whole
// point — Ice Block cast while stunned.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(true, 0)
)),
None
);
// …and skipping one arm does not skip the ladder: a silenced-and-stunned caster whose
// spell counters only the stun still refuses on silence below it.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED | SILENCED,
Some(100),
false,
Some(&cast),
&mut |types: &[u32]| {
// Exempt from the stun arm `{12}` only; the silence arm `{27, 12, 60}` is not.
benilla_formats::CcExemption {
exempt: types == [12],
mechanic: 0,
}
}
)),
Some(0x60)
);
}
/// **The mechanic rides out with the refusal** (decision 1948) — the half that turns
/// "Can't do that while %s" into a sentence. It is the ONE client-local refusal that carries
/// an argument word, and it is `None` for every arm that fell to its own reason.
#[test]
fn the_renamed_refusal_carries_the_mechanic_and_the_others_carry_nothing() {
let cast = spell(1);
let scan = |exempt: bool, mechanic: u32| {
move |_: &[u32]| benilla_formats::CcExemption { exempt, mechanic }
};
// Rejected by a blocking aura: reason `0x8d` AND the mechanic that names it.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 12)),
Some((0x8d, Some(12)))
);
// No aura: the arm's own reason, and NO argument — the message has no `%s` to fill.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 0)),
Some((0x64, None))
);
// Exempt: nothing at all.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(true, 0)),
None
);
}
/// **A dead caster skips all six** (`0x60980d`'s `jle` on `UNIT_FIELD_HEALTH`) — a corpse is
/// refused by an earlier rung, and reporting a stun over it would be the wrong line.
#[test]
fn a_dead_caster_takes_no_crowd_control_refusal() {
let cast = spell(1);
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(0),
false,
Some(&cast),
&mut no_auras()
)),
None
);
assert_eq!(
reason_of(cast_cc_refusal(
0,
Some(0),
true,
Some(&cast),
&mut no_auras()
)),
None
);
// Alive again, and the arm is back.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
Some(1),
false,
Some(&cast),
&mut no_auras()
)),
Some(0x64)
);
// No health field at all is not death — the descriptor simply has not landed.
assert_eq!(
reason_of(cast_cc_refusal(
STUNNED,
None,
false,
Some(&cast),
&mut no_auras()
)),
Some(0x64)
);
}
}
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) struct PendingBindOnUse(pub(crate) Option<PendingUse>);
pub(crate) type PendingUse = crate::ui_items::ItemUse;
/// The three resources the deferral needs, bundled so a drain can take them in one parameter
/// (the shape `crate::ui_action::CastLadder` established). [`Items`] is deliberately NOT in here:
/// (the shape `crate::spell::CastLadder` established). [`Items`] is deliberately NOT in here:
/// every caller already holds it, and a second `ResMut<Items>` in one system is a conflict.
#[derive(SystemParam)]
pub(crate) struct BindGate<'w> {
+4 -3
View File
@@ -9,7 +9,7 @@
//! item-targeted cast (`SPELL_EFFECT_ENCHANT_ITEM`/`_TEMPORARY`, `Targets = 0x10`). Since
//! decision 0923 this window owns none of that machinery — `DoCraft` goes down the ONE cast
//! ladder like every other caster surface, and the resolver's item arm raises the ONE targeting
//! cursor, which the bag and paper-doll click seams complete ([`crate::ui_action::targeting`]).
//! cursor, which the bag and paper-doll click seams complete ([`crate::spell::targeting`]).
//! The private `PendingItemCast` this file used to carry — a second targeting state with its own
//! arm, its own bag-click completion and its own cursor overlay, bypassing every ladder rung
//! including the reagent check an enchant most needs — is gone.
@@ -29,7 +29,8 @@ use benilla_ui::script::{CraftReagent, CraftRecipe, CraftState, CraftTooltip, Ui
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{cast_target, CastCommit, CastLadder, PlayerActions, Spells};
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_items::{count_of, item_icon, InventoryScope};
use crate::ui_script::UiInput;
use crate::ui_spellbook::SkillLines;
@@ -309,7 +310,7 @@ fn feed_craft(
/// Drain the Lua intents: every `DoCraft` goes down the ONE cast ladder, and the resolver decides
/// what happens next — an enchant's `Targets = 0x10` word arms the targeting cursor's item half
/// (decision 0923; the bag / paper-doll click completes it, in `ui_action::targeting`), a rod
/// (decision 0923; the bag / paper-doll click completes it, in `spell::targeting`), a rod
/// craft's zero word commits immediately. `CloseCraft` closes the window; a pick armed by it is
/// the one targeting word, cancelled the ordinary ways (ESC, right-click, a new cast).
///
+10 -10
View File
@@ -197,8 +197,8 @@ pub(super) fn drain_bag_autostores(
pub(super) fn drain_inventory_uses(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
targeting: crate::ui_action::cast_target::CastTargeting,
mut ladder: crate::ui_action::CastLadder,
targeting: crate::spell::cast_target::CastTargeting,
mut ladder: crate::spell::CastLadder,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
@@ -262,13 +262,13 @@ pub(super) fn drain_container_uses(
bank: Res<crate::ui_bank::BankOpen>,
mut equip_sound: MessageWriter<crate::sound::AutoEquipSound>,
mut item_text: ResMut<crate::ui_item_text::ItemTextOpen>,
targeting: crate::ui_action::cast_target::CastTargeting,
targeting: crate::spell::cast_target::CastTargeting,
// The client-side pending ("gray") lock — the right-click-open arm arms it (decision 0916).
mut pending_items: ResMut<PendingItemOps>,
// The loot-target latch — the right-click-open arm is one of its five arm sites, and the one
// that lets `SMSG_LOOT_RESPONSE`'s admission gate recognise an item loot (decision 1531).
mut loot_latch: ResMut<crate::ui_loot::LootLatch>,
mut ladder: crate::ui_action::CastLadder,
mut ladder: crate::spell::CastLadder,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
@@ -900,7 +900,7 @@ mod tests {
.init_resource::<crate::ui_loot::LootLatch>()
.init_resource::<crate::target::Selection>()
.init_resource::<crate::net::SelfGuid>()
.init_resource::<crate::ui_action::cast_target::AutoSelfCast>()
.init_resource::<crate::spell::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::spell::PendingCast>()
@@ -911,7 +911,7 @@ mod tests {
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::ui_action::targeting::SpellTargeting>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<Items>()
.insert_resource(NetCommands(tx));
@@ -1114,8 +1114,8 @@ pub(super) fn drain_bind_confirm_answers(
/// taken on the first, so a doubled accept re-uses nothing.
pub(super) fn drain_bind_on_use_confirms(
script: Option<NonSendMut<UiScript>>,
targeting: crate::ui_action::cast_target::CastTargeting,
mut ladder: crate::ui_action::CastLadder,
targeting: crate::spell::cast_target::CastTargeting,
mut ladder: crate::spell::CastLadder,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
@@ -1649,7 +1649,7 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_loot::LootLatch>()
.init_resource::<crate::target::Selection>()
.init_resource::<crate::net::SelfGuid>()
.init_resource::<crate::ui_action::cast_target::AutoSelfCast>()
.init_resource::<crate::spell::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::spell::PendingCast>()
@@ -1660,7 +1660,7 @@ mod bind_confirm_tests {
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::ui_action::targeting::SpellTargeting>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<Items>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
+4 -4
View File
@@ -789,7 +789,7 @@ pub(crate) fn item_use_route(
/// IsCasting check, and `6e4f33` forwards the item to the requirement validator `0x6094f0`. The
/// commit `SendCast 0x6e54f0` then picks the opcode from it (`0x6e57d8 push 0xab`). So an item
/// use takes the whole ladder — cooldown, GCD, in-flight, mounted, moving, form, reagents, target
/// bind, range — and [`crate::ui_action::CastLadder::send`] is where all of that lives (decision
/// bind, range — and [`crate::spell::CastLadder::send`] is where all of that lives (decision
/// 0914; verified at the bytes in wow-re's `system/ui/scratch/disasm-full.txt`, corroborated by
/// its `action-item-slot.md` §8 and `cursor-system.md` §8.4a).
///
@@ -823,8 +823,8 @@ pub(crate) fn item_use_route(
/// Returns whether anything left for the server — `false` for the reference's silent no-op.
pub(crate) fn send_item_use(
it: ItemUse,
ctx: &crate::ui_action::cast_target::CastContext,
ladder: &mut crate::ui_action::CastLadder,
ctx: &crate::spell::cast_target::CastContext,
ladder: &mut crate::spell::CastLadder,
script: &mut benilla_ui::script::UiScript,
gate: &mut crate::ui_bind_confirm::BindGate,
suppress: bool,
@@ -911,7 +911,7 @@ pub(crate) fn send_item_use(
ladder.send(
spell,
ctx,
crate::ui_action::CastCommit::Item {
crate::spell::CastCommit::Item {
bag_index: it.bag_index,
slot: it.slot,
entry: it.entry,
@@ -1,5 +1,5 @@
//! The two enchant-apply confirms (decision 0928, EnchantConfirm.xml): the Lua wiring between the
//! events `ui_action::targeting`'s item-bind gate fires and the shared StaticPopup engine, driven
//! events `spell::targeting`'s item-bind gate fires and the shared StaticPopup engine, driven
//! exactly as that gate drives it.
//!
//! What these pin is the seam, not the gate: that `BIND_ENCHANT` and `REPLACE_ENCHANT` reach a
+3 -2
View File
@@ -21,7 +21,7 @@
//! MOD_SHAPESHIFT spell, the 48-slot aura scan for a force-admitted one ([`form_active`]).
//! - **isCastable**: the active form reads hardcoded-castable; otherwise the usability predicate
//! `0x6e3d60` — the SAME full walk the action bar's `IsUsableAction` runs
//! ([`crate::ui_action::usable`], decision 0269's fold-back: reagents, forms, stealth, aura
//! ([`crate::spell::usable`], decision 0269's fold-back: reagents, forms, stealth, aura
//! states, the power gate).
//! - **cooldown**: the form spell's own spell/category read ([`Cooldowns::info`]).
//! - **Click** (`CastShapeshiftForm`): the ACTIVE form CANCELS (`CMSG_CANCEL_AURA`) — unless
@@ -66,8 +66,9 @@ use benilla_ui::script::{ShapeshiftFormView, UiScript};
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::spell::Cooldowns;
use crate::spell::{cast_target, usable, CastCommit, CastLadder};
use crate::target::Selection;
use crate::ui_action::{cast_target, usable, CastCommit, CastLadder, PlayerActions, Spells};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
+2 -4
View File
@@ -41,10 +41,8 @@ use benilla_ui::script::{ScriptValue, SpellBookState, SpellSlotView, SpellTabVie
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{
cast_target, melee_auto_attack_icon, ranged_weapon_icon, CastCommit, CastLadder, PlayerActions,
Spells,
};
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::ui_action::{melee_auto_attack_icon, ranged_weapon_icon, PlayerActions, Spells};
use crate::ui_script::{gate, UiInput};
use crate::ui_unit::UnitFeed;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
+2 -1
View File
@@ -1,5 +1,6 @@
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Markup — ported verbatim from probes/text-glyph/src/markup.rs
// Markup — ported verbatim from decision 0068's text-glyph probe (`probes/text-glyph`, retired
// from the tree by 2331; git history keeps it)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A `|H<link>|h<text>|h` hyperlink a run sits inside: the link payload (`item:2000:0:0:0`,
+5 -5
View File
@@ -60,7 +60,7 @@ struct ViewCtx<'a> {
commands: &'a NetCommands,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
/// The talent spell-modifier tables — the cost cell shows the RESOLVED cost, which since
/// `SPELLMOD_COST` landed means the modified one (`crate::ui_action::usable::power_cost`).
/// `SPELLMOD_COST` landed means the modified one (`crate::spell::usable::power_cost`).
spell_mods: &'a crate::spell::SpellModifiers,
/// The VM's own `GlobalStrings.lua` (decision 2045) — every cell this builder composes is a
/// key, and this is where they resolve. `text` is the `%d`-filling twin the `$`-engine's
@@ -121,7 +121,7 @@ fn spell_tooltip_view(
// HAPPINESS_COST has no GlobalStrings entry and no 5875 player spell reaches powerType 4;
// the unit word is a dead arm kept for the array's shape.
let resolved_cost = vctx.store.map_or(d.mana_cost, |s| {
crate::ui_action::usable::power_cost(d, s, vctx.spell_mods)
crate::spell::usable::power_cost(d, s, vctx.spell_mods)
});
let cost = {
// The one `0x6e7130` table, not a local `if power_type == 1` — decision 2117 found three
@@ -302,9 +302,9 @@ fn spell_tooltip_view(
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
// spell must be passive. The percentages are already percents on the wire.
let chance = chance_line(d, vctx.store);
let item_met = vctx.store.is_none_or(|s| {
crate::ui_action::usable::equipped_item_fits(d, s, vctx.items, vctx.commands)
});
let item_met = vctx
.store
.is_none_or(|s| crate::spell::usable::equipped_item_fits(d, s, vctx.items, vctx.commands));
// Reagents (law §3.8): the named slots, `count > 1` suffixed, a slot the player is short of
// wrapped in the builder's inline red. A reagent whose item template hasn't streamed yet is
// simply absent from this snapshot — `feed_spell_tooltips` re-pushes when it lands, which is
+2 -1
View File
@@ -39,7 +39,8 @@ use crate::creature_anim::{CastEvent, CastEventKind};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{cast_target, CastCommit, CastLadder, PlayerActions, Spells};
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_items::{count_of, item_icon, InventoryScope};
use crate::ui_script::UiInput;
use crate::ui_spellbook::SkillLines;
+4 -3
View File
@@ -432,7 +432,7 @@ struct PlateWorld<'w, 's> {
// The cursor, for the plate-rect hover (OnEnter — the yellow name, this frame's rects).
window: Query<'w, 's, &'static Window, With<bevy::window::PrimaryWindow>>,
// The pending ground-target cast — the plate's `+0x3c` hit-test veto (`0x7cba30`).
targeting: Res<'w, crate::ui_action::SpellTargeting>,
targeting: Res<'w, crate::spell::SpellTargeting>,
}
/// Gate + draw, every frame: decide which units carry a plate (into [`VPlates`], the
@@ -532,7 +532,7 @@ fn drive_vplates(
script.set_nameplate_hit_test_veto(
world
.targeting
.wants(crate::ui_action::targeting::TargetingWants::Location),
.wants(crate::spell::targeting::TargetingWants::Location),
);
let hovered_key = script.hovered_nameplate();
let clicked = script.take_nameplate_clicks();
@@ -695,7 +695,8 @@ fn drive_vplates(
// (the plate's logical size) are hoisted above the loop — the border resample keys off them.
// Geometry trace for the vplates capture (`WOW_VPLATE_TRACE=1`): the exact plate rects
// this frame, in logical px — the machine-side check the capture PNG can't give
// (fill/border/text hues overlap under zoom).
// (fill/border/text hues overlap under zoom). The `vpl` lines of the shared trace below
// are read back by `scripts/vplsum.py`.
static TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let trace = *TRACE.get_or_init(|| std::env::var("WOW_VPLATE_TRACE").as_deref() == Ok("1"));
// The full seat (`0x509ec0`): the desired rect TOP-anchored on the raw projected point
@@ -72,7 +72,6 @@ fn is_instrument(rel: &str) -> bool {
[
"/capture/",
"/bin/",
"/probes/",
"debug_panel",
"shape_gate",
"framexml_diff",
@@ -1195,7 +1195,7 @@ fn real_item_target_family_and_its_gate_columns() {
assert!(
is_enchant(effects[0]) || !(is_enchant(effects[1]) || is_enchant(effects[2])),
"spell {} hides its enchant effect outside slot 0 ({effects:?}) — the one-slot read \
in `ui_action::targeting::item_target_refusal` would miss it",
in `spell::targeting::item_bind_verdict` would miss it",
u32_at(r, 0).unwrap_or(0)
);
}
+18 -4
View File
@@ -45,8 +45,12 @@ fn spell_caster(item_or_caster: u64, caster_slot: u64) -> u64 {
}
}
/// Decode one server packet into zero or more [`SessionEvent`]s. Pure: no I/O, no state. Packets the
/// client doesn't model yield an empty list.
/// Decode one server packet into zero or more [`SessionEvent`]s. Pure: no I/O, no state.
///
/// **Exhaustive on purpose** (decision 2331): every `ServerPacket` variant is named here, so a
/// parse arm that grows a variant nobody decodes is a compile error at this match, never a silent
/// no-op. The 2026-09 opcode-coverage audit found the old `_ => Vec::new()` catch-all unreachable —
/// every produced variant was consumed — which is exactly the state a wildcard cannot keep.
pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
match packet {
ServerPacket::UpdateObject { objects } => decode_objects(objects),
@@ -214,6 +218,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
bag_slot,
}]
}
// Reason 0 is `EQUIP_ERR_OK`: vmangos's `Player::SendEquipError` sends the one-byte body
// for it too (`Player.cpp:11688`), and there is no failure to show.
ServerPacket::InventoryChangeFailure { .. } => Vec::new(),
ServerPacket::AttackStart { attacker, victim } => {
vec![SessionEvent::AttackStart { attacker, victim }]
}
@@ -1118,12 +1125,19 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
}],
// An opcode with NO parse arm at all — surface it so the app can tally the coverage gap
// (the debug panel's dropped-opcode instrument); the silent fall-through hid whole wire
// families. Parsed-but-unmodelled packets (the `_` below) stay deliberate no-ops.
// families.
ServerPacket::Other { opcode } => vec![SessionEvent::PacketDropped {
opcode,
unparseable: false,
}],
_ => Vec::new(),
// The handshake-only five: `world::session` consumes them before the world loop ever
// hands a packet here (auth, character create/delete, the addon-info reply). Named, not
// wildcarded — see the function doc.
ServerPacket::AuthChallenge { .. }
| ServerPacket::AuthResponse { .. }
| ServerPacket::CharCreate { .. }
| ServerPacket::CharDelete { .. }
| ServerPacket::AddonInfo { .. } => Vec::new(),
}
}
+165 -8
View File
@@ -323,6 +323,30 @@ impl SrpClientChallenge {
large_safe_prime: [u8; 32],
server_public_key: PublicKey,
salt: [u8; 32],
) -> SrpClientChallenge {
Self::new_with_rng(
&mut thread_rng(),
username,
password,
generator,
large_safe_prime,
server_public_key,
salt,
)
}
/// [`Self::new`] with the ephemeral's draw source injected. `new` hands it `thread_rng`; the
/// tests hand it a scripted one, so each guard in the loop below is exercised on purpose —
/// a draw known to trip it, then a clean one — rather than by the ~1-in-137 chance a random
/// sweep gives each (decision 2331).
fn new_with_rng<R: RngCore>(
rng: &mut R,
username: NormalizedString,
password: NormalizedString,
generator: u8,
large_safe_prime: [u8; 32],
server_public_key: PublicKey,
salt: [u8; 32],
) -> SrpClientChallenge {
let n = from_le(&large_safe_prime);
let g = BigInt::from(generator);
@@ -338,7 +362,7 @@ impl SrpClientChallenge {
let last = draw == MAX_EPHEMERAL_DRAWS;
let mut private_key = [0u8; 32];
thread_rng().fill_bytes(&mut private_key);
rng.fill_bytes(&mut private_key);
let a = from_le(&private_key);
// A = g^a mod N
@@ -600,21 +624,25 @@ mod tests {
assert!(!is_width_stable(&[]));
}
/// The crate's guarantee: every handshake we hand out is one both serialization conventions read
/// identically, so no value carries a high-order zero byte. `A` lands ambiguous ~1 draw in 137
/// and `K`/`M1` ~1 in 256 each, so over this many draws a regression that dropped any one guard
/// is caught with ~97% probability. (The live gate is `benilla-protocol`'s `srp_encoding_probe`,
/// which forces each case against a real realmd.)
/// The crate's guarantee as a property: every handshake we hand out is one both serialization
/// conventions read identically, so no value carries a high-order zero byte. Seeded (2331), so
/// the 128 server keys and every ephemeral drawn against them are the same on every machine —
/// a red run reproduces. The per-guard proof is `each_encoding_guard_rejects_the_draw_it_exists_for`;
/// the live gate is `benilla-protocol`'s `srp_encoding_probe`, which forces each case against
/// a real realmd.
#[test]
fn every_drawn_handshake_is_encoding_unambiguous() {
use rand::{rngs::StdRng, SeedableRng};
let user = NormalizedString::new("alice").unwrap();
let pass = NormalizedString::new("password1").unwrap();
let mut rng = StdRng::seed_from_u64(0x5875);
for i in 0..128u32 {
let mut b = [0u8; 32];
thread_rng().fill_bytes(&mut b);
rng.fill_bytes(&mut b);
b[31] |= 0x80; // the shape of server key `logon` keeps
let salt = std::array::from_fn(|j| (j as u8).wrapping_mul(11).wrapping_add(i as u8));
let c = SrpClientChallenge::new(
let c = SrpClientChallenge::new_with_rng(
&mut rng,
user.clone(),
pass.clone(),
GENERATOR,
@@ -635,6 +663,135 @@ mod tests {
}
}
/// A draw source that hands out exactly the private keys it was given, in order.
struct Scripted(std::collections::VecDeque<[u8; 32]>);
impl RngCore for Scripted {
fn next_u32(&mut self) -> u32 {
unreachable!("the ephemeral is drawn with fill_bytes")
}
fn next_u64(&mut self) -> u64 {
unreachable!("the ephemeral is drawn with fill_bytes")
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
let key = self
.0
.pop_front()
.expect("a scripted draw was left for this");
dest.copy_from_slice(&key);
}
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> {
self.fill_bytes(dest);
Ok(())
}
}
/// The server side of the fixture the guard vectors were found against: a fixed `B` (with the
/// high bit `logon` keeps) and a fixed salt. Change either and the vectors below stop meaning
/// anything — the test says so.
const FIXTURE_B: [u8; 32] = [
5, 42, 79, 116, 153, 190, 227, 8, 45, 82, 119, 156, 193, 230, 11, 48, 85, 122, 159, 196,
233, 14, 51, 88, 125, 162, 199, 236, 17, 54, 91, 128,
];
const FIXTURE_SALT: [u8; 32] = [
3, 14, 25, 36, 47, 58, 69, 80, 91, 102, 113, 124, 135, 146, 157, 168, 179, 190, 201, 212,
223, 234, 245, 0, 11, 22, 33, 44, 55, 66, 77, 88,
];
/// Private keys that, against [`FIXTURE_B`]/[`FIXTURE_SALT`], trip exactly one guard each —
/// found once by a seeded search (`StdRng::seed_from_u64(2331)`, first hit per guard) and
/// pinned. `TRIPS_A`: `A` gets a high-order zero byte. `TRIPS_S`: `S` gets a low-order zero
/// byte. `TRIPS_K`: the interleaved key gets a high-order zero. `TRIPS_M1`: the proof does.
/// `CLEAN` passes all four.
const TRIPS_A: [u8; 32] = [
237, 250, 2, 239, 106, 197, 124, 117, 132, 5, 226, 189, 212, 217, 169, 146, 39, 212, 214,
11, 198, 57, 225, 84, 17, 219, 168, 107, 118, 105, 11, 95,
];
const TRIPS_S: [u8; 32] = [
205, 28, 249, 67, 94, 223, 246, 194, 118, 47, 78, 155, 220, 133, 163, 151, 42, 72, 65, 90,
37, 9, 156, 83, 38, 132, 96, 6, 129, 245, 202, 203,
];
const TRIPS_K: [u8; 32] = [
2, 176, 65, 251, 162, 142, 23, 37, 53, 25, 44, 220, 201, 39, 234, 48, 141, 207, 127, 19, 0,
93, 140, 154, 16, 181, 118, 225, 147, 237, 174, 28,
];
const TRIPS_M1: [u8; 32] = [
143, 237, 243, 79, 25, 151, 163, 98, 11, 221, 191, 202, 69, 148, 140, 221, 123, 95, 152,
146, 230, 232, 181, 86, 18, 72, 78, 135, 95, 129, 234, 248,
];
const CLEAN: [u8; 32] = [
52, 226, 21, 16, 93, 106, 129, 125, 56, 197, 129, 14, 149, 1, 254, 105, 63, 23, 9, 190, 46,
113, 240, 125, 128, 130, 150, 46, 174, 240, 93, 249,
];
/// `A = g^a mod N` for a private key, as the constructor computes it.
fn public_key_of(private_key: &[u8; 32]) -> [u8; 32] {
let n = from_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
to_padded_32_le(&BigInt::from(GENERATOR).modpow(&from_le(private_key), &n))
}
/// The crate's guarantee, exercised on purpose (decision 2331): each guard in the draw loop is
/// handed a private key KNOWN to trip it, then a clean one, and the handshake that comes back
/// must be the clean draw's. A dropped guard fails its case outright — the old random sweep
/// caught a dropped guard with "~97 %" probability, which is a 3 % escape by design.
#[test]
fn each_encoding_guard_rejects_the_draw_it_exists_for() {
let user = NormalizedString::new("alice").unwrap();
let pass = NormalizedString::new("password1").unwrap();
let server = PublicKey::from_le_bytes(FIXTURE_B).unwrap();
let clean_a = public_key_of(&CLEAN);
// The fixture still means what the vectors say: each trips its guard, CLEAN trips none.
assert!(
!is_width_stable(&public_key_of(&TRIPS_A)),
"TRIPS_A no longer trips A"
);
assert!(is_width_stable(&clean_a), "CLEAN no longer passes A");
for (guard, bad) in [
("A", TRIPS_A),
("S", TRIPS_S),
("K", TRIPS_K),
("M1", TRIPS_M1),
] {
let mut rng = Scripted([bad, CLEAN].into_iter().collect());
let c = SrpClientChallenge::new_with_rng(
&mut rng,
user.clone(),
pass.clone(),
GENERATOR,
LARGE_SAFE_PRIME_LITTLE_ENDIAN,
server,
FIXTURE_SALT,
);
assert_eq!(
c.client_public_key(),
&clean_a,
"guard {guard}: the tripping draw was accepted instead of the clean one"
);
assert!(
rng.0.is_empty(),
"guard {guard}: the clean draw was never reached"
);
assert!(is_width_stable(c.client_public_key()));
assert!(is_width_stable(&c.session_key));
assert!(is_width_stable(c.client_proof()));
}
// And a draw that trips nothing is taken first time.
let mut rng = Scripted([CLEAN, TRIPS_A].into_iter().collect());
let c = SrpClientChallenge::new_with_rng(
&mut rng,
user,
pass,
GENERATOR,
LARGE_SAFE_PRIME_LITTLE_ENDIAN,
server,
FIXTURE_SALT,
);
assert_eq!(c.client_public_key(), &clean_a);
assert_eq!(rng.0.len(), 1, "a clean first draw is the handshake");
}
/// A salt is fixed for the life of an account rather than redrawn per handshake, so an ambiguous
/// one is not a flaky login but an account that can never log in.
#[test]
+3 -33
View File
@@ -17,6 +17,9 @@
//! out the whole `MOVEVIEW*` family in place. A parser that finds bindings by scanning text (and
//! any count taken by `grep`) registers those six as real, which is why the counts here are the
//! ones this module's own parse returns and why `a_commented_out_binding_is_not_a_binding` exists.
//! The counts are pinned against the install itself by
//! `benilla_app::bindings::commands::tests::the_installs_bindings_xml_reads_as_the_parsers_header_says`
//! — there, because there is where the patch chain is (this crate reads no install).
//!
//! ```xml
//! <Bindings>
@@ -345,37 +348,4 @@ mod tests {
let e = parse("<Bindings><Binding name=\"X\"></Bindings>").expect_err("malformed");
assert!(e.to_string().starts_with("malformed Bindings.xml:"));
}
/// Parses the REAL shipped file when `BENILLA_BINDINGS_XML` points at one (never committed —
/// extract `Interface\FrameXML\Bindings.xml` with benilla-extract). Skips silently otherwise,
/// matching `toc.rs`'s and `framexml.rs`'s pattern so the gates never depend on client data.
///
/// The numbers are the ones this module's header quotes, so a wrong count here is either a
/// wrong file or a header that has drifted from the client.
#[test]
fn real_bindings_xml_when_available() {
let Ok(path) = std::env::var("BENILLA_BINDINGS_XML") else {
return;
};
let text = std::fs::read_to_string(&path).expect("reading BENILLA_BINDINGS_XML");
let binds = parse(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
assert_eq!(
binds.len(),
228,
"1.12.1 ships 228 live bindings — the other six are inside a comment"
);
assert!(binds.iter().all(|b| !b.name.is_empty()));
assert!(
!binds.iter().any(|b| b.name.starts_with("MOVEVIEW")),
"the commented-out family must not register"
);
assert_eq!(binds.iter().filter(|b| b.run_on_up).count(), 94);
assert_eq!(binds.iter().filter(|b| b.header.is_some()).count(), 13);
assert_eq!(binds.iter().filter(|b| b.hidden).count(), 12);
assert_eq!(
binds[0].header.as_deref(),
Some("MOVEMENT"),
"the file opens on the MOVEMENT section"
);
}
}
-20
View File
@@ -983,24 +983,4 @@ print(x)</Script>
let grandchild_name = resolve_name("$parentText", &child_name);
assert_eq!(grandchild_name, "PlayerFrameHealthBarText");
}
/// Parses a REAL shipped FrameXML file when `BENILLA_FRAMEXML` points at one (never committed —
/// extract e.g. `Interface\FrameXML\PlayerFrame.xml` with benilla-extract). Skips silently
/// otherwise, matching toc.rs's `real_manifest_when_available` pattern so CI/gates don't depend
/// on client data.
#[test]
fn real_framexml_when_available() {
let Ok(path) = std::env::var("BENILLA_FRAMEXML") else {
return;
};
let text = std::fs::read_to_string(&path).expect("reading BENILLA_FRAMEXML");
let doc = parse(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
assert!(
!doc.items.is_empty(),
"{path}: a real FrameXML file has at least one top-level item"
);
for w in &doc.warnings {
eprintln!("{path}: warning: {w}");
}
}
}
-43
View File
@@ -980,49 +980,6 @@ mod loader_tests {
assert!(s.eval::<bool>("return Another == nil").unwrap());
}
/// Env-gated smoke test over a real extracted FrameXML file (never committed; extract with
/// benilla-extract and point `BENILLA_FRAMEXML_LOAD` at it). Parses + loads with a provider over
/// the file's own directory, asserts no hard *parse/loader-internal* error, and reports how many
/// frames materialized plus the API/handler gaps the file surfaced. Skips silently otherwise,
/// matching framexml.rs's `real_framexml_when_available` pattern so gates never depend on client
/// data.
#[test]
fn real_framexml_load_when_available() {
let Ok(path) = std::env::var("BENILLA_FRAMEXML_LOAD") else {
return;
};
let text = std::fs::read_to_string(&path).expect("reading BENILLA_FRAMEXML_LOAD");
let dir = std::path::Path::new(&path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
// Provider: resolve an XML/Lua reference against the file's directory, trying the path as
// given and by basename (Blizzard paths use backslashes and are dir-relative).
let provider = move |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let s = UiScript::new().unwrap();
let doc = framexml::parse(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
let report = load(&s, &doc, &provider);
eprintln!("== {path}: {} frames materialized ==", report.frames);
for e in &report.errors {
eprintln!(" error: {e}");
}
for w in &report.warnings {
eprintln!(" warn: {w}");
}
// Script errors surfaced by any OnLoad that ran against missing API:
for e in s.errors() {
eprintln!(" script-error: {e}");
}
}
/// `<StatusBar>` LoadXML extras (RF-28): value attributes, `<BarTexture>` + `<BarColor>`
/// children, orientation — landing in the widget state and scaling the extracted bar quad.
#[test]
+1 -1
View File
@@ -843,7 +843,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// no packet). The 1/nil return is load-bearing exactly like SpellStopCasting's above: the
// ESC chain's rung (`UIParent.lua:1490`, `elseif ( SpellStopTargeting() ) then`) must fall
// through to the game menu when nothing was targeting. The host drains the trigger
// (`benilla::ui_action::targeting`) and clears its mode.
// (`benilla::spell::targeting`) and clears its mode.
g.set(
"SpellStopTargeting",
lua.create_function(|lua, ()| {
@@ -1511,7 +1511,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// DropItemOnUnit(unit) — drop the cursor's held item onto a unit (`0x48d960`). Two legs in the
// reference: the PET leg feeds the pet, the PLAYER leg opens a trade. Queues the raw token and
// nothing else — every gate reads state this VM does not hold, so the app owns all of them
// (`ui_action::targeting::drop_item_on_unit`).
// (`ui_action::drop_item`).
//
// This binding **existed in our shipped `PetFrame_OnClick` before it existed here**: the
// handler transcribed the reference's three legs faithfully, and the middle one called a nil
+1 -1
View File
@@ -442,7 +442,7 @@ pub struct UnitState {
/// two of those six are hardcoded *local-player* readers taking no token at all. `"player"`
/// takes a fast path to the cached local-player GUID inside `0x515970` and then reads the
/// identical bit. One wire flag answers this for every token, which is what this one field
/// is; `benilla::ui_action::usable` already reads the same bit off the same descriptor.
/// is; `benilla::spell::usable` already reads the same bit off the same descriptor.
pub in_combat: bool,
}
-17
View File
@@ -347,21 +347,4 @@ mod tests {
let toc = Toc::parse("# only a comment\n\n");
assert!(toc.directives.is_empty() && toc.files.is_empty());
}
/// Parses a REAL shipped manifest when `BENILLA_TOC` points at one (never committed — extract
/// e.g. `Interface\FrameXML\FrameXML.toc` with benilla-extract and export the path). Skips
/// silently otherwise, so CI/gates don't depend on client data.
#[test]
fn real_manifest_when_available() {
let Ok(path) = std::env::var("BENILLA_TOC") else {
return;
};
let text = std::fs::read_to_string(&path).expect("reading BENILLA_TOC");
let toc = Toc::parse(&text);
assert!(!toc.files.is_empty(), "a real manifest lists files");
assert!(
!toc.interface_versions().is_empty(),
"a real manifest declares ## Interface"
);
}
}
@@ -26,6 +26,13 @@ use bevy::tasks::{block_on, AsyncComputeTaskPool, Task};
/// just-streamed static geometry going solid a few frames late is not yet reachable.
const ATTACH_BUDGET: Duration = Duration::from_millis(2);
/// The attach budget as a resource, so a test can hold it still (2331). Absent — always, in the
/// client — [`finish_colliders`] spends [`ATTACH_BUDGET`] of wall clock; present, it spends this.
/// `Duration::ZERO` is the fully deterministic setting: exactly one attach per call, because the
/// deadline is checked *after* each attach and a zero budget is already past.
#[derive(Resource)]
pub(super) struct AttachBudget(pub(super) Duration);
/// A static collider being built on the async compute pool (see [`build_collider_task`]); once the
/// parry shape is ready, [`finish_colliders`] inserts it (with `layers`, for the WMO walk/camera
/// audiences) onto this entity. Building off-thread keeps a big-WMO / terrain stream-in from hitching
@@ -58,6 +65,18 @@ impl PendingCollider {
static_body,
}
}
/// A collider that is already built — the state a completed task leaves behind — so a test
/// can stage a whole burst as ready without a thread pool or a sleep (2331).
#[cfg(test)]
fn ready(collider: Collider, layers: Option<CollisionLayers>, static_body: bool) -> Self {
Self {
task: None,
built: Some(collider),
layers,
static_body,
}
}
}
/// Attach off-thread-built colliders to their entities, at most [`ATTACH_BUDGET`] worth per frame.
@@ -126,7 +145,10 @@ pub(super) fn finish_colliders(
// collider still makes progress rather than stalling the queue forever (the rule
// `stream_terrain` already applies to its tile spawns).
let mut attached = 0u32;
let deadline = Instant::now() + ATTACH_BUDGET;
let budget = world
.get_resource::<AttachBudget>()
.map_or(ATTACH_BUDGET, |b| b.0);
let deadline = Instant::now() + budget;
for entity in ready {
// The entity may have streamed out (despawned) while its collider was building, or had its
// slot reused — a no-op rather than a panic.
@@ -624,27 +646,63 @@ mod tests {
app
}
/// The other half of the queue — pass 1: a task the pool has finished hands its shape to
/// `built` on the poll and attaches on the same call. Polled until done rather than slept for:
/// the outcome is the pool's, the timing is not asserted.
#[test]
fn a_finished_task_hands_its_shape_over_and_attaches() {
let mut app = test_app();
let (verts, tris) = grid(4);
let task = build_collider_task(verts, tris);
let e = app
.world_mut()
.spawn((Transform::default(), PendingCollider::new(task, None, true)))
.id();
for _ in 0..100_000 {
app.world_mut().run_system_once(finish_colliders).unwrap();
if app.world().entity(e).contains::<Collider>() {
break;
}
std::thread::yield_now();
}
assert!(
app.world().entity(e).contains::<Collider>(),
"the pool finished the build (or never did — a hung pool, not a budget)"
);
assert!(
app.world().entity(e).contains::<RigidBody>(),
"a static body rides the attach"
);
assert!(app.world().get::<PendingCollider>(e).is_none());
}
/// The budget must both bite (a burst is spread over frames) and never lose a finished build.
/// The second half is the subtle one: a completed `Task` cannot be polled twice, so a deferred
/// attach has to park its shape rather than leave it in the task.
///
/// Deterministic since 2331: the burst is staged already-built (`PendingCollider::ready`) and
/// the budget is held at zero through [`AttachBudget`], so one call attaches exactly one. The
/// earlier shape slept 500 ms for a real pool and then asserted that forty `grid(16)` attaches
/// outran a 2 ms wall clock — true on the machine it was written on, false on a faster one,
/// and false the other way on a starved pool.
#[test]
fn attach_budget_spreads_a_burst_without_losing_colliders() {
const N: usize = 40;
let mut app = test_app();
app.insert_resource(AttachBudget(std::time::Duration::ZERO));
let (verts, tris) = grid(16);
let entities: Vec<Entity> = (0..N)
.map(|_| {
let task = build_collider_task(verts.clone(), tris.clone());
let collider = Collider::trimesh(verts.clone(), tris.clone());
app.world_mut()
.spawn((Transform::default(), PendingCollider::new(task, None, true)))
.spawn((
Transform::default(),
PendingCollider::ready(collider, None, true),
))
.id()
})
.collect();
// Let the pool finish every build, so the whole burst is ready at once — the tile-boundary
// case the budget exists for.
std::thread::sleep(std::time::Duration::from_millis(500));
let attached = |app: &mut App| {
entities
.iter()
@@ -653,11 +711,10 @@ mod tests {
};
app.world_mut().run_system_once(finish_colliders).unwrap();
let after_one = attached(&mut app);
assert!(after_one > 0, "budget starved the queue: nothing attached");
assert!(
after_one < N,
"budget never bit: all {N} attached in one frame ({after_one})"
assert_eq!(
attached(&mut app),
1,
"a zero budget is past after the first attach: exactly one lands per call"
);
// Drain: every remaining build must still land, none lost to the deferral.
@@ -19,7 +19,7 @@ use super::{
/// [`Site`] can be pinned without hunting. `WOW_DUMP_TILE=map,x,y` (default Azeroth,31,49 —
/// Goldshire).
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
#[ignore = "instrument: run by hand — cargo test -p benilla-world --lib wmo_portal::audit::light_probe -- --ignored --nocapture"]
fn dump_tile_placements() {
let spec = std::env::var("WOW_DUMP_TILE").unwrap_or_else(|_| "Azeroth,31,49".into());
let parts: Vec<&str> = spec.split(',').collect();
@@ -66,7 +66,7 @@ fn raw_downray_winner(model: &WmoModel, probe: [f32; 3]) -> Option<(usize, f32)>
/// `X` = an EXTERIOR group's face won (exterior lane), `·` = no floor. A patchwork of D/d/digit
/// across the walkable floor is the per-step lane-flip mechanism made visible.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
#[ignore = "instrument: run by hand — cargo test -p benilla-world --lib wmo_portal::audit::light_probe -- --ignored --nocapture"]
fn forge_floor_lane_map() {
let subject = load_subject(BLACKSMITH.wmo, Some(&BLACKSMITH));
let model = &subject.model;
@@ -193,7 +193,7 @@ fn face_stack(model: &WmoModel, x: f32, y: f32, max_z: f32) {
/// verdict (`area_down_ray` — the entity classifier's exact leg, probe at feet+0.1, terrain-raced).
/// Prints an XY map around the doorway so the g00/g11 ownership boundary is visible at a glance.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
#[ignore = "instrument: run by hand — cargo test -p benilla-world --lib wmo_portal::audit::light_probe -- --ignored --nocapture"]
fn inn_corridor_light_probe() {
let subject = load_subject(GOLDSHIRE.wmo, Some(&GOLDSHIRE));
let model = &subject.model;
@@ -373,7 +373,7 @@ fn inn_corridor_light_probe() {
/// `z + centre` (`benilla-extract m2coll <model>` prints the box; the two Stratholme portcullises
/// that found 0776 read `exterior` at their spawn z and `BAKE g02` at their centres).
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
#[ignore = "instrument: run by hand — cargo test -p benilla-world --lib wmo_portal::audit::light_probe -- --ignored --nocapture"]
fn world_point_light_probe() {
let spec =
std::env::var("WOW_LIGHT_AT").unwrap_or_else(|_| "Kalimdor,1629.4,-4373.4,31.3".into());
@@ -1,4 +1,4 @@
//! The WMO portal-cull **audit harness** (test-only, `#[ignore]`d — needs the game data): load a real
//! The WMO portal-cull **audit harness** (test-only; data-gated, not ignored — see below): load a real
//! building *at its real placement*, with the real ADT terrain under it, and sweep the two invariants
//! the client upholds by construction. Every violation prints as a deterministic repro (exact
//! model-space coordinates + a per-portal hop trace), so a director-found "it vanishes here" becomes a
@@ -16,7 +16,18 @@
//! test asserts the race does not over-fire and seal the mine: a standing point on a tunnel floor,
//! under the hill, still reads INSIDE.
//!
//! Run: `cargo test -p benilla wmo_ -- --ignored --nocapture`
//! **In the gate since 2331.** The seven audits that assert an invariant (this file's four, and
//! `pin`'s three site regressions) are ordinary data-gated tests: `wow_data_or_skip!` skips them
//! where there is no install and `BENILLA_REQUIRE_DATA` refuses that skip where the gate found
//! one (2329). They were `#[ignore]`d for "needs the game data" from before 1175 put an install
//! beside every slot, and nothing ever ran them — the `--ignored` line below was the only runner,
//! and it named a crate this module had left. Cost, measured 2026-09-22: six under a second each,
//! `wmo_pvs_audit` ~14 s (the standing-point × camera sweep). What stays `#[ignore]`d is the
//! instruments — `pin`'s census and probe (aimed by `WOW_PIN_*`) and `light_probe`'s four — run by
//! hand, `--nocapture`, from the command each attribute carries.
//!
//! Run the whole harness, instruments included:
//! `cargo test -p benilla-world --lib wmo_portal::audit -- --include-ignored --nocapture`
//!
//! [`light_probe`] (sibling module) reuses this harness's placed subjects for the entity-LIGHT
//! down-ray probes — per-point verdict/lane maps of the inn corridor and the forge floor.
@@ -552,8 +563,9 @@ const OUTSIDE_HEIGHTS: [f32; 4] = [0.5, 1.7, 4.0, 9.0];
/// on a tunnel floor is below the hill's surface, so the terrain is not on their down-segment at all,
/// and the WMO must still win the column.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn wmo_outside_audit() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
let subject = load_subject(FARGODEEP.wmo, Some(&FARGODEEP));
let model = &subject.model;
let placed = subject.placed.as_ref().expect("placed");
@@ -732,8 +744,9 @@ fn interior_standing_eye(model: &WmoModel, gi: usize) -> Option<[f32; 3]> {
}
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn wmo_pvs_audit() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
// The env override names a subject with no known placement — then there is no terrain leg.
let over = std::env::var("WOW_AUDIT_WMO").ok();
let internal = over.clone().unwrap_or_else(|| GOLDSHIRE.wmo.to_string());
@@ -891,8 +904,9 @@ fn wmo_pvs_audit() {
/// own camera. Leg C must name the pocket's room; the terrain race must still own the columns where
/// its answer is right (the hilltop lid ~272 yd above the tunnel).
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn wmo_camera_void_audit() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
let subject = load_subject(DEADMINES.wmo, Some(&DEADMINES));
// The zone-in head: areatrigger 78's destination (-14.5732, -385.475, 62.4561) + head height,
// mapped through the MODF placement (uid 170633) into B-local space.
@@ -941,8 +955,9 @@ fn wmo_camera_void_audit() {
/// 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() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
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.
@@ -70,7 +70,7 @@ fn site_from_env() -> Site {
/// so a "which group is that thing on my screen" question becomes a grep instead of a guess. Same
/// `WOW_PIN_*` targeting as the pin probe.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
#[ignore = "instrument: aimed by WOW_PIN_*, run by hand — cargo test -p benilla-world --lib wmo_portal::audit::pin -- --ignored --nocapture"]
fn wmo_group_census() {
let site = site_from_env();
let subject = load_subject(site.wmo, Some(&site));
@@ -335,7 +335,7 @@ fn wmo_group_census() {
/// The subject defaults to [`UNDERCITY`] (B26); `WOW_PIN_WMO` + `WOW_PIN_UID` + `WOW_PIN_MAP` +
/// `WOW_PIN_TILE` retarget it at another placement. Output is Blizzard-derived — keep it out of the repo.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
#[ignore = "instrument: aimed by WOW_PIN_*, run by hand — cargo test -p benilla-world --lib wmo_portal::audit::pin -- --ignored --nocapture"]
fn wmo_pin_probe() {
fn xyz(var: &str, default: [f32; 3]) -> [f32; 3] {
let Ok(s) = std::env::var(var) else {
@@ -541,8 +541,9 @@ fn wmo_pin_probe() {
/// lane, and the chain breaks below the exterior-lit courtyards, so **g61 must be on the scene
/// lane**. The control is the seed itself, which must stay on the interior lane.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
let site = SHADOWFANG;
let subject = load_subject(site.wmo, Some(&site));
let placed = subject
@@ -647,8 +648,9 @@ fn shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog() {
/// because it is what makes the conjunct load-bearing rather than theoretical. The conjunct itself
/// is `crate::interior`'s `a_settled_anchor_follows_its_rooms_fog_gate_without_moving`.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn shadowfang_sees_true_interiors_that_are_off_the_fog_chain() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
let site = SHADOWFANG;
let subject = load_subject(site.wmo, Some(&site));
let placed = subject
@@ -750,8 +752,9 @@ fn shadowfang_sees_true_interiors_that_are_off_the_fog_chain() {
/// The measurement is the **gain**: groups the Pass-2 walk entered that no window admitted directly
/// and Pass 1 never reached. Under the marking implementation the gain is zero by construction.
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn darnassus_pass_two_walks_on_from_the_window() {
// The data gate (2331): a skip where no install is, a failure where the gate says one is.
let _data = benilla_formats::wow_data_or_skip!();
use std::collections::HashSet;
/// Splits the flood's `entered` steps into Pass 1's and Pass 2's, and records which groups a