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:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
|
||||
+16
-18
@@ -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!(
|
||||
+13
-2
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+11
-11
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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`]).
|
||||
+6
-6
@@ -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;
|
||||
@@ -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(¬_channeled)), None);
|
||||
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(¬_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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(¬_channeled)), None);
|
||||
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(¬_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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user