diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 413fd5d5..042f69b6 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -33,7 +33,9 @@ tracy = ["bevy/trace_tracy"] # Opt-in Chrome-trace profiling: `cargo run --features trace_chrome` writes a `trace-.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] diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index 68ba9173..2da077bd 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -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> { let data = benilla_formats::wow_data_or_skip!(None); diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 66db6952..3d23fc4f 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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> { diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index 57617f70..a65affd9 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -197,7 +197,7 @@ pub(crate) struct DisplayedCursor(pub(crate) crate::target::WorldCursor); fn drive_displayed_cursor( world: Res, over_ui: Res, - targeting: Res, + targeting: Res, script: Option>, // 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) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 636f9106..96a3ba82 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -2881,7 +2881,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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); diff --git a/crates/benilla-app/src/death/mod.rs b/crates/benilla-app/src/death/mod.rs index 5ff3bedf..dd22cbf3 100644 --- a/crates/benilla-app/src/death/mod.rs +++ b/crates/benilla-app/src/death/mod.rs @@ -582,8 +582,8 @@ fn drain_death( script: Option>, mut death_net: ResMut, net: Res, - 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, // 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), diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 1e4d87a1..b8ab8f4a 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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); diff --git a/crates/benilla-app/src/sound/mix_tap.rs b/crates/benilla-app/src/sound/mix_tap.rs index a929b952..416111c7 100644 --- a/crates/benilla-app/src/sound/mix_tap.rs +++ b/crates/benilla-app/src/sound/mix_tap.rs @@ -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 `: what the mix was doing, when, in +//! numbers (clipping, level, silence, steps). use bevy::prelude::*; use kira::effect::{Effect, EffectBuilder}; diff --git a/crates/benilla-app/src/sound/probe.rs b/crates/benilla-app/src/sound/probe.rs index 0a2ea157..1c968dfc 100644 --- a/crates/benilla-app/src/sound/probe.rs +++ b/crates/benilla-app/src/sound/probe.rs @@ -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 //! diff --git a/crates/benilla-app/src/ui_action/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs similarity index 98% rename from crates/benilla-app/src/ui_action/cast_send.rs rename to crates/benilla-app/src/spell/cast_send.rs index 8134dfb5..3123ec50 100644 --- a/crates/benilla-app/src/ui_action/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -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::(); world.init_resource::(); world.init_resource::(); - world.init_resource::(); + world.init_resource::(); // 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::>(); @@ -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::() + .resource_mut::() // 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::() - .active(), + !world.resource::().active(), "the commit clears the one word" ); assert!( diff --git a/crates/benilla-app/src/ui_action/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs similarity index 98% rename from crates/benilla-app/src/ui_action/cast_target.rs rename to crates/benilla-app/src/spell/cast_target.rs index b6f744e4..829c2ee7 100644 --- a/crates/benilla-app/src/ui_action/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -240,7 +240,11 @@ pub(crate) struct CastTargeting<'w, 's> { reputations: Res<'w, Reputations>, self_transform: Query<'w, 's, &'static Transform, With>, 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>, } 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, mut auto: ResMut) { + if ev.is("autoSelfCast") { + auto.0 = ev.flag(); + } +} + impl Default for AutoSelfCast { fn default() -> Self { Self(true) diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index 7a166078..0017aeec 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -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); -/// **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); - /// ── 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 diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index f0f1fbfa..3806b9ee 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -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::() .init_resource::() .init_resource::() - .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .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); diff --git a/crates/benilla-app/src/spell/mods.rs b/crates/benilla-app/src/spell/mods.rs index 00d00938..224d69e3 100644 --- a/crates/benilla-app/src/spell/mods.rs +++ b/crates/benilla-app/src/spell/mods.rs @@ -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; diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 45e01b10..3f0be0fb 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -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>, 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, 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, + 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, 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 { 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, running: Option| { let mut app = App::new(); app.add_message::() @@ -2987,8 +3027,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::(); + .init_resource::(); let self_e = app.world_mut().spawn((Guid(10), SelfPlayer)).id(); app.world_mut() .resource_mut::() @@ -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, @@ -3018,8 +3058,7 @@ mod tests { mut pending: ResMut, mut queued_melee: ResMut, mut cooldowns: ResMut, - mut auto_repeat: ResMut, - mut chain: ResMut| { + mut auto_repeat: ResMut| { 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::().0.clone(); let still_armed = app .world() .resource::() .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::::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::::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::::new(), + None, "a reply for a spell we do not have in flight chains nothing" ); } diff --git a/crates/benilla-app/src/ui_action/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs similarity index 99% rename from crates/benilla-app/src/ui_action/targeting/cursor.rs rename to crates/benilla-app/src/spell/targeting/cursor.rs index 9aa9c2e9..193e7357 100644 --- a/crates/benilla-app/src/ui_action/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -351,7 +351,7 @@ mod tests { world.spawn((SelfPlayer, Transform::default())); world.resource_mut::().enter( 2120, - crate::ui_action::CastCommit::Spell, + crate::spell::CastCommit::Spell, word, ); world diff --git a/crates/benilla-app/src/ui_action/targeting/item.rs b/crates/benilla-app/src/spell/targeting/item.rs similarity index 97% rename from crates/benilla-app/src/ui_action/targeting/item.rs rename to crates/benilla-app/src/spell/targeting/item.rs index 6045513f..c2fc417e 100644 --- a/crates/benilla-app/src/ui_action/targeting/item.rs +++ b/crates/benilla-app/src/spell/targeting/item.rs @@ -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>, enchants: Option>, mut parked: ResMut, - 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) ); } } diff --git a/crates/benilla-app/src/ui_action/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs similarity index 99% rename from crates/benilla-app/src/ui_action/targeting/mod.rs rename to crates/benilla-app/src/spell/targeting/mod.rs index 9914b4f7..e42e4b53 100644 --- a/crates/benilla-app/src/ui_action/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -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`]). diff --git a/crates/benilla-app/src/ui_action/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs similarity index 98% rename from crates/benilla-app/src/ui_action/targeting/world.rs rename to crates/benilla-app/src/spell/targeting/world.rs index 3b637a64..0189c28f 100644 --- a/crates/benilla-app/src/ui_action/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -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, - 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, - 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::().occlusion = @@ -299,7 +299,7 @@ mod tests { let (mut world, rx, id) = fixture(); world.resource_mut::().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. diff --git a/crates/benilla-app/src/ui_action/usable.rs b/crates/benilla-app/src/spell/usable.rs similarity index 99% rename from crates/benilla-app/src/ui_action/usable.rs rename to crates/benilla-app/src/spell/usable.rs index 584644b1..d4c1634e 100644 --- a/crates/benilla-app/src/ui_action/usable.rs +++ b/crates/benilla-app/src/spell/usable.rs @@ -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; diff --git a/crates/benilla-app/src/spell/validator.rs b/crates/benilla-app/src/spell/validator.rs new file mode 100644 index 00000000..28841352 --- /dev/null +++ b/crates/benilla-app/src/spell/validator.rs @@ -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, + dist_sq: Option, +) -> Option { + 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, + charmed_by_other: bool, + spell: Option<&SpellDisplay>, + exempt: &mut impl FnMut(&[u32]) -> benilla_formats::CcExemption, +) -> Option<(u8, Option)> { + 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)> { + 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 { + 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 = 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)>) -> Option { + v.map(|(r, _)| r) + } + + /// A live caster, nobody else at the reins, no auras. + fn cc(flags: u32, d: &SpellDisplay) -> Option { + 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) + ); + } +} diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 72090c5c..e84dc482 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -73,7 +73,7 @@ pub(super) fn world_right_click_payload( pub(super) fn select_on_plate_click( mut plate: ResMut, press: Res, - ground: Res, + ground: Res, mut selection: ResMut, mut seam: crate::creature_anim::AttackSeam, self_q: Query<(&Guid, Has), With>, @@ -140,7 +140,7 @@ pub(super) fn select_on_click( self_q: Query<(&Guid, Has), With>, payload_held: Res, mut greeting: MessageWriter, - ground: Res, + ground: Res, click_cfg: Res, // 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::(); world.init_resource::(); world.init_resource::(); - world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::>(); world.init_resource::>(); diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index d0280f41..57ccadb3 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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 diff --git a/crates/benilla-app/src/target/reticle.rs b/crates/benilla-app/src/target/reticle.rs index d7962ae8..2b38dbd8 100644 --- a/crates/benilla-app/src/target/reticle.rs +++ b/crates/benilla-app/src/target/reticle.rs @@ -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; diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 2cefc908..8f2a921f 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -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, - 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. /// diff --git a/crates/benilla-app/src/ui_action/drop_item.rs b/crates/benilla-app/src/ui_action/drop_item.rs index f60ca8b1..cb028a21 100644 --- a/crates/benilla-app/src/ui_action/drop_item.rs +++ b/crates/benilla-app/src/ui_action/drop_item.rs @@ -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, press: Res, mut clicks: MessageReader, - mut ladder: super::CastLadder, + mut ladder: crate::spell::CastLadder, ) { let Some(mut script) = script else { clicks.clear(); diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 96b2900d..77a49dd1 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -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, mut auto: ResMut) { - 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::() .init_resource::() .init_resource::() @@ -354,9 +322,6 @@ impl Plugin for UiActionPlugin { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() .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), ), ); } diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 88989cc3..53881841 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -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, } -/// 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, - dist_sq: Option, -) -> Option { - 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, - charmed_by_other: bool, - spell: Option<&SpellDisplay>, - exempt: &mut impl FnMut(&[u32]) -> benilla_formats::CcExemption, -) -> Option<(u8, Option)> { - 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)> { - 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 { - 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, Res, Res, - Res, + Res, Res, Res, ), @@ -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::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -958,7 +614,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -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 = 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)>) -> Option { - v.map(|(r, _)| r) - } - - /// A live caster, nobody else at the reins, no auras. - fn cc(flags: u32, d: &SpellDisplay) -> Option { - 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) - ); - } } diff --git a/crates/benilla-app/src/ui_bind_confirm.rs b/crates/benilla-app/src/ui_bind_confirm.rs index d0fd018c..b68dcfcc 100644 --- a/crates/benilla-app/src/ui_bind_confirm.rs +++ b/crates/benilla-app/src/ui_bind_confirm.rs @@ -147,7 +147,7 @@ pub(crate) struct PendingBindOnUse(pub(crate) Option); 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` in one system is a conflict. #[derive(SystemParam)] pub(crate) struct BindGate<'w> { diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 7c3de858..8d38021f 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -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). /// diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 3839d671..1fd239bc 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -197,8 +197,8 @@ pub(super) fn drain_bag_autostores( pub(super) fn drain_inventory_uses( script: Option>, self_q: Query<&ObjectStore, With>, - 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, mut gate: crate::ui_bind_confirm::BindGate, ) { @@ -262,13 +262,13 @@ pub(super) fn drain_container_uses( bank: Res, mut equip_sound: MessageWriter, mut item_text: ResMut, - 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, // 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, - mut ladder: crate::ui_action::CastLadder, + mut ladder: crate::spell::CastLadder, mut ui_errors: ResMut, mut gate: crate::ui_bind_confirm::BindGate, ) { @@ -900,7 +900,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -911,7 +911,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .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>, - 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, mut gate: crate::ui_bind_confirm::BindGate, ) { @@ -1649,7 +1649,7 @@ mod bind_confirm_tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1660,7 +1660,7 @@ mod bind_confirm_tests { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .insert_resource(NetCommands(tx)); app.world_mut().spawn(( diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index 910afdbf..8385a6b1 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/enchant_confirm_tests.rs b/crates/benilla-app/src/ui_script/enchant_confirm_tests.rs index a8f94d6c..9cbed19e 100644 --- a/crates/benilla-app/src/ui_script/enchant_confirm_tests.rs +++ b/crates/benilla-app/src/ui_script/enchant_confirm_tests.rs @@ -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 diff --git a/crates/benilla-app/src/ui_shapeshift.rs b/crates/benilla-app/src/ui_shapeshift.rs index 7163d7a6..55a2e9b5 100644 --- a/crates/benilla-app/src/ui_shapeshift.rs +++ b/crates/benilla-app/src/ui_shapeshift.rs @@ -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; diff --git a/crates/benilla-app/src/ui_spellbook.rs b/crates/benilla-app/src/ui_spellbook.rs index d80cf0e7..df61194e 100644 --- a/crates/benilla-app/src/ui_spellbook.rs +++ b/crates/benilla-app/src/ui_spellbook.rs @@ -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}; diff --git a/crates/benilla-app/src/ui_text/markup.rs b/crates/benilla-app/src/ui_text/markup.rs index 8d002d29..c34beb2c 100644 --- a/crates/benilla-app/src/ui_text/markup.rs +++ b/crates/benilla-app/src/ui_text/markup.rs @@ -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|h|h` hyperlink a run sits inside: the link payload (`item:2000:0:0:0`, diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 99ee5178..bd267c66 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_tradeskill.rs b/crates/benilla-app/src/ui_tradeskill.rs index b5663dbe..add46447 100644 --- a/crates/benilla-app/src/ui_tradeskill.rs +++ b/crates/benilla-app/src/ui_tradeskill.rs @@ -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; diff --git a/crates/benilla-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index da3f8e01..32bd6cc0 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -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>, // 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 = 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 diff --git a/crates/benilla-app/tests/reference_strings.rs b/crates/benilla-app/tests/reference_strings.rs index 70ec307c..84ee0e78 100644 --- a/crates/benilla-app/tests/reference_strings.rs +++ b/crates/benilla-app/tests/reference_strings.rs @@ -72,7 +72,6 @@ fn is_instrument(rel: &str) -> bool { [ "/capture/", "/bin/", - "/probes/", "debug_panel", "shape_gate", "framexml_diff", diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 3b6fed88..172d39e3 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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) ); } diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index df2fa473..f8f45592 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -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 { match packet { ServerPacket::UpdateObject { objects } => decode_objects(objects), @@ -214,6 +218,9 @@ pub fn decode(packet: ServerPacket) -> Vec { 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 { }], // 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(), } } diff --git a/crates/benilla-srp/src/lib.rs b/crates/benilla-srp/src/lib.rs index 49127923..480efb6c 100644 --- a/crates/benilla-srp/src/lib.rs +++ b/crates/benilla-srp/src/lib.rs @@ -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( + 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] diff --git a/crates/benilla-ui/src/bindings_xml.rs b/crates/benilla-ui/src/bindings_xml.rs index a34b7ab6..dd3c1d31 100644 --- a/crates/benilla-ui/src/bindings_xml.rs +++ b/crates/benilla-ui/src/bindings_xml.rs @@ -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 //! @@ -345,37 +348,4 @@ mod tests { let e = parse("").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" - ); - } } diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs index a5b0725d..64fb0f86 100644 --- a/crates/benilla-ui/src/framexml.rs +++ b/crates/benilla-ui/src/framexml.rs @@ -983,24 +983,4 @@ print(x) 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}"); - } - } } diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index c7a411fc..9ba3e525 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -980,49 +980,6 @@ mod loader_tests { assert!(s.eval::("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> { - 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}"); - } - } - /// `` LoadXML extras (RF-28): value attributes, `` + `` /// children, orientation — landing in the widget state and scaling the extracted bar quad. #[test] diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index f1d02b92..92d2010c 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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, ()| { diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index a8795fd7..3228d603 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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 diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 322ee4fd..14c1f1b7 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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, } diff --git a/crates/benilla-ui/src/toc.rs b/crates/benilla-ui/src/toc.rs index e8ed8f71..c9c8d548 100644 --- a/crates/benilla-ui/src/toc.rs +++ b/crates/benilla-ui/src/toc.rs @@ -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" - ); - } } diff --git a/crates/benilla-world/src/terrain_stream/collider.rs b/crates/benilla-world/src/terrain_stream/collider.rs index 2d9ec8b3..fbefe4ad 100644 --- a/crates/benilla-world/src/terrain_stream/collider.rs +++ b/crates/benilla-world/src/terrain_stream/collider.rs @@ -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, 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::() + .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::() { + break; + } + std::thread::yield_now(); + } + assert!( + app.world().entity(e).contains::(), + "the pool finished the build (or never did — a hung pool, not a budget)" + ); + assert!( + app.world().entity(e).contains::(), + "a static body rides the attach" + ); + assert!(app.world().get::(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 = (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. diff --git a/crates/benilla-world/src/wmo_portal/audit/light_probe.rs b/crates/benilla-world/src/wmo_portal/audit/light_probe.rs index 2de9880b..d809e8fa 100644 --- a/crates/benilla-world/src/wmo_portal/audit/light_probe.rs +++ b/crates/benilla-world/src/wmo_portal/audit/light_probe.rs @@ -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 ` 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()); diff --git a/crates/benilla-world/src/wmo_portal/audit/mod.rs b/crates/benilla-world/src/wmo_portal/audit/mod.rs index 28a1c043..8cf57735 100644 --- a/crates/benilla-world/src/wmo_portal/audit/mod.rs +++ b/crates/benilla-world/src/wmo_portal/audit/mod.rs @@ -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. diff --git a/crates/benilla-world/src/wmo_portal/audit/pin.rs b/crates/benilla-world/src/wmo_portal/audit/pin.rs index 9f9e10aa..db957d66 100644 --- a/crates/benilla-world/src/wmo_portal/audit/pin.rs +++ b/crates/benilla-world/src/wmo_portal/audit/pin.rs @@ -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