point-and-click casting: Blizzard lands where you aim

- ground-targeted spells work end to end: press the button, get the targeting cursor, click the ground, and the cast ships on the wire the way the real client sends it
- a reticle sized to the spell's real radius rides the cursor, and on the click the storm model, falling shards and area sound all land at the point
- character screens: a newly created character comes back selected on the list, and the authored layout scales to small windows instead of clipping the bottom controls off
- blob shadows ride their receiver's clip transform and the raster bias that kept them faint is measured and fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-30 19:40:05 +07:00
co-authored by Claude Fable 5
parent d3e05f32e5
commit 56c62c7591
57 changed files with 2600 additions and 119 deletions
+142 -3
View File
@@ -16,8 +16,11 @@
//! `spell-visual-apply.md` §5), and **field 10 = the missile's in-flight LOOP sound**
//! ([`SoundEntries`] id — Fireball's `FireMissileLoop`, the thrown dagger's `WeaponLoop`; the
//! client's per-missile loop handle `CMissile+0x44`, wow-re `w2f1.md`). Field 6 (`hasMissile`)
//! is never read — the spawn gate is `Spell.dbc` Speed alone; field 8 (`missilePathType`) is
//! dead-by-absence; field 13 (the caster's ground-arrival kit) is not read here yet. Stage
//! is never read by the missile spawn (its gate is `Spell.dbc` Speed alone) — its ONE reader
//! is the GO dest one-shot's suppressor ([`VisualStages::missile_gate`], 0797); field 8
//! (`missilePathType`) is dead-by-absence; **fields 11/12/13 are the dest-anchored block**
//! ([`VisualStages::area_gate`]/[`VisualStages::area_effect`]/[`VisualStages::area_kit`] —
//! wow-re `dynobject-visual-machine.md`, 0797). Stage
//! semantics (decision 0107 verdict 2): the stage sets *lifetime policy* only —
//! every populated slot on a reached row fires, precast persisting (reaped spell-id-keyed)
//! while cast/impact self-terminate.
@@ -120,11 +123,28 @@ pub struct VisualStages {
/// 1143 "Mining Impact" (the pick clang), Herb's 91 carries 1142, the smithing crafts' 395
/// carries 1143 (the hammer). `None` = no strike sound.
pub strike_sound: Option<u32>,
/// Field 6 (`+0x18`): the missile gate the dest one-shot checks — `SMSG_SPELL_GO` spawns a
/// CEffect at the packet's DEST point only when this is **0** and [`Self::area_effect`] is
/// set (the client's `0x6e8088`–`0x6e8143`; wow-re `spell-go-dest-effect.md`). Nonzero =
/// the missile owns the arrival, no dest one-shot.
pub missile_gate: u32,
/// Field 11 (`+0x2c`): the gate on a DynamicObject's **own** `.mdx` (visual A) — the model
/// resolve at `0x5d57c0` requires this ≠ 0 before reading [`Self::area_effect`] (wow-re
/// `dynobject-visual-machine.md`).
pub area_gate: u32,
/// Field 12 (`+0x30`): the `SpellVisualEffectName` id of the **dest-anchored model** — a
/// DynamicObject's own `.mdx` (visual A, gated by [`Self::area_gate`]) and the GO dest
/// one-shot's model (gated by [`Self::missile_gate`] == 0). NOT the kit table.
pub area_effect: u32,
/// Field 13 (`+0x34`): the `SpellVisualKit` id of the **area kit** — a DynamicObject's
/// emitter chain (`0x5d55c0`: CharProcType scan for 9 → the hardcoded shard-model table)
/// and its looping area sound (the kit's own field-13 SoundEntries). `0` = none.
pub area_kit: u32,
}
/// One `SpellVisualKit.dbc` row's body-animation + sound + the nine attach-point emitter slots
/// (the columns consumed through decision 0099 phase 3; see the module doc for what remains).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct VisualKit {
/// The `AnimationData.dbc` id this kit plays on the unit (`None` = no animation for this kit
/// — see the module doc's none-sentinel finding; common on impact kits).
@@ -135,6 +155,20 @@ pub struct VisualKit {
/// order — slot `i` attaches at [`KIT_SLOT_TAGS`]`[i]`. Both none-sentinels fold to `None`
/// like [`Self::anim_id`]. Iterate populated slots via [`Self::effects`].
pub effect_slots: [Option<u32>; 9],
/// The four CharProc blocks — `CharProcType` (fields 15–18, int) with `CharParamZero`
/// (19–22, f32) and `CharParamOne` (23–26, f32). The dynobj emitter chain scans for the
/// FIRST type **9** slot: param0 encodes the shard-model table index (the exact small-int
/// decode `bits(param0 + 512.0) >> 14 & 0xff`, `0x5d55c0`), param1 is the emit rate the
/// graphics-quality factor multiplies (wow-re `dynobject-visual-machine.md`).
pub char_procs: [KitCharProc; 4],
}
/// One `SpellVisualKit` CharProc block (see [`VisualKit::char_procs`]).
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct KitCharProc {
pub proc_type: u32,
pub param0: f32,
pub param1: f32,
}
impl VisualKit {
@@ -296,6 +330,10 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog
missile_attach: g(9),
missile_sound: u32_at(r, 10).and_then(some_unless_none),
strike_sound: u32_at(r, 14).and_then(some_unless_none),
missile_gate: g(6),
area_gate: g(11),
area_effect: g(12),
area_kit: g(13),
},
);
}
@@ -317,12 +355,21 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog
for (i, slot) in effect_slots.iter_mut().enumerate() {
*slot = u32_at(r, 3 + i).and_then(some_unless_none);
}
// The four CharProc blocks: type (int column) + two f32 param columns, read as the
// client does — param0/param1 are IEEE floats in the file (the `fadd 512.0` decode and
// the emit-rate multiply are float ops at `0x5d55c0`/`0x6eb930`).
let char_procs = std::array::from_fn(|i| KitCharProc {
proc_type: u32_at(r, 15 + i).unwrap_or(0),
param0: u32_at(r, 19 + i).map(f32::from_bits).unwrap_or(0.0),
param1: u32_at(r, 23 + i).map(f32::from_bits).unwrap_or(0.0),
});
kits.insert(
id,
VisualKit {
anim_id,
sound,
effect_slots,
char_procs,
},
);
}
@@ -467,6 +514,10 @@ mod tests {
missile_attach: g(9),
missile_sound: u32_at(r, 10).and_then(some_unless_none),
strike_sound: u32_at(r, 14).and_then(some_unless_none),
missile_gate: g(6),
area_gate: g(11),
area_effect: g(12),
area_kit: g(13),
},
);
}
@@ -482,6 +533,9 @@ mod tests {
missile_attach: 1,
missile_sound: None,
strike_sound: None,
// The synthetic row builder writes 0 into field 6 and the dest-anchored block
// (the REAL Fireball row's missile_gate = 1 is pinned in the real-data test).
..Default::default()
}
);
assert_eq!(
@@ -594,6 +648,12 @@ mod tests {
// Field 10: the fireball's in-flight loop (SoundEntries 3011 → FireMissileLoop.wav).
missile_sound: Some(3011),
strike_sound: None,
// Field 6 set = a missile owns the arrival: the GO dest one-shot gate is closed
// (0797); the dest-anchored columns are empty on a projectile nuke.
missile_gate: 1,
area_gate: 0,
area_effect: 0,
area_kit: 0,
}
);
// The gathering/work strike sounds (decision 0562): Mining's visual 93 carries the pick
@@ -711,3 +771,82 @@ mod tests {
);
}
}
#[cfg(test)]
mod ground_fx_data_tests {
use super::*;
/// The dest-anchored chain on the REAL data — 0797's mandatory per-spell data check (the
/// wow-re read pinned the GATE; whether each spell passes it is a table fact). Skips
/// without client data. Every value here corroborates the live vmangos wire capture:
/// Blizzard's dynobj RADIUS was 8.0 (row 14), Flamestrike's 5.0 (row 8).
#[test]
fn ground_aoe_chain_reads_the_dest_anchored_block() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let catalog = crate::load_spell_catalog(&mut chain).expect("spells");
let visuals = load_spell_visual_catalog(&mut chain).expect("visuals");
let radii = crate::load_spell_radii(&mut chain).expect("radii");
// Blizzard 10 → visual 259: both visuals — its own model AND a type-9 shard emitter
// whose table index decodes to 0 (the same model), rate 5/s, sound 7.
let d = catalog.get(10).unwrap();
assert_eq!((d.visual, d.effect_radius_index), (259, [14, 0, 0]));
assert_eq!(
radii.get(14).map(|r| r.radius),
Some(8.0),
"the wire radius"
);
let v = visuals.stages(259).unwrap();
assert_eq!(
(v.missile_gate, v.area_gate, v.area_effect, v.area_kit),
(0, 1, 398, 609)
);
assert_eq!(
visuals.effect_path(398),
Some("Spells\\Blizzard_Impact_Base.mdx")
);
let k = visuals.kit(609).unwrap();
assert_eq!(k.sound, Some(7));
let proc = k.char_procs.iter().find(|p| p.proc_type == 9).unwrap();
assert_eq!((proc.param0, proc.param1), (0.0, 5.0));
// Flamestrike 2120 → visual 33: its own model + sound, NO type-9 proc (a burning
// patch has no falling shards) — the emitter half is data-absent, not code-gated.
let d = catalog.get(2120).unwrap();
assert_eq!((d.visual, d.effect_radius_index), (33, [8, 8, 0]));
assert_eq!(radii.get(8).map(|r| r.radius), Some(5.0), "the wire radius");
let v = visuals.stages(33).unwrap();
assert_eq!(
(v.missile_gate, v.area_gate, v.area_effect, v.area_kit),
(0, 1, 420, 695)
);
assert_eq!(
visuals.effect_path(420),
Some("Spells\\Flamestrike_Impact_Base.mdx")
);
let k = visuals.kit(695).unwrap();
assert_eq!(k.sound, Some(3077));
assert!(k.char_procs.iter().all(|p| p.proc_type != 9));
// Rain of Fire 5740 → visual 329: shard-table index 1 (its own model again), rate 5/s.
let v = visuals.stages(catalog.get(5740).unwrap().visual).unwrap();
assert_eq!((v.missile_gate, v.area_gate, v.area_effect), (0, 1, 448));
let proc = visuals
.kit(v.area_kit)
.unwrap()
.char_procs
.iter()
.find(|p| p.proc_type == 9)
.copied()
.unwrap();
assert_eq!((proc.param0, proc.param1), (1.0, 5.0));
// All three pass the GO dest one-shot gate (field 6 == 0 ∧ field 12 ≠ 0) — the burst
// fires for each; Fireball (missile_gate 1, pinned in the loader test above) does not.
}
}
@@ -31,6 +31,12 @@ impl SpellRadiusCatalog {
self.rows.get(&index)
}
/// Fixture constructor — tests (the [`crate::SpellCatalog::from_displays`] convention).
/// The live path is [`load_spell_radii`].
pub fn from_rows(rows: HashMap<u32, SpellRadius>) -> Self {
Self { rows }
}
pub fn len(&self) -> usize {
self.rows.len()
}
@@ -22,8 +22,9 @@ use benilla_protocol::{decode, EntityKind, WorldSession, WORLD_PORT};
use clap::Parser;
use probes::{
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, Loot, Probe, QueryNames, Quest,
QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor, WorldState,
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, Probe,
QueryNames, Quest, QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot,
Vendor, WorldState,
};
use world::{DeathArc, Tracked, World};
@@ -62,6 +63,13 @@ struct Cli {
/// either proves the round trip).
#[arg(long)]
spells: bool,
/// Capture the dest-anchored effect wire for a ground cast of this spell id (the B132
/// follow-up instrument): GM-learn it + GM-fill mana, cast at own feet (mask 0x40), and dump
/// every DynamicObject create raw (labeled `DYNAMICOBJECT_*` fields), the SPELL_GO, and the
/// removal edge with its lifetime. Pair with `--seconds 25`+ so a channel + its object's whole
/// life fits the window. Needs a GM account (the deploy's probes are gmlevel 6).
#[arg(long)]
groundfx: Option<u32>,
/// Live-verify the melee-swing wire (decision 0073): GM-teleport (`.go xyz`) onto a Northshire
/// Kobold Vermin spawn, `CMSG_ATTACKSWING` the nearest streamed creature, and require ≥1
/// `SMSG_ATTACKERSTATEUPDATE` to arrive and decode (attacker/victim/hitInfo/damage). Needs a GM
@@ -330,6 +338,9 @@ fn main() -> Result<()> {
if cli.spells {
probes.push(Box::new(Spells::default()));
}
if let Some(spell) = cli.groundfx {
probes.push(Box::new(GroundFx::new(spell)));
}
if let Some(n) = cli.use_pack_slot {
probes.push(Box::new(UsePackSlot { n }));
}
@@ -432,6 +443,7 @@ fn main() -> Result<()> {
EntityKind::Player,
EntityKind::Unit,
EntityKind::GameObject,
EntityKind::DynamicObject,
EntityKind::Other,
] {
let mut group: Vec<(&u64, &Tracked)> = world
@@ -0,0 +1,258 @@
//! `--groundfx <spell_id>`: capture the **dest-anchored effect wire** a ground cast produces —
//! the instrument for the B132 follow-up ("the cast lands but nothing shows"). GM-learn the
//! spell, GM-fill mana (any class can then channel it), cast it at our own feet through the same
//! `cast_spell_at_dest` body the client's world-click commit sends, and dump raw everything the
//! server anchors at the point: every `DynamicObject` create (all `DYNAMICOBJECT_*` fields,
//! labeled per vmangos `UpdateFields_1_12_1.h`), the `SPELL_GO`, and the removal edge with its
//! measured lifetime. Run with `--seconds 25`+ so a channeled spell's whole life fits the window
//! (Blizzard 10 channels 8 s and its object outlives the channel).
use std::time::Instant;
use anyhow::{bail, Result};
use benilla_protocol::{EntityKind, SessionEvent};
use crate::probes::{Ctx, Probe};
/// `DYNAMICOBJECT_*` UpdateField labels (vmangos `UpdateFields_1_12_1.h:325-334`; OBJECT_END=6).
fn dyn_field_label(index: u16) -> &'static str {
match index {
0 => "OBJECT_FIELD_GUID_LO",
1 => "OBJECT_FIELD_GUID_HI",
2 => "OBJECT_FIELD_TYPE",
3 => "OBJECT_FIELD_ENTRY",
4 => "OBJECT_FIELD_SCALE_X",
6 => "DYNAMICOBJECT_CASTER_LO",
7 => "DYNAMICOBJECT_CASTER_HI",
8 => "DYNAMICOBJECT_BYTES",
9 => "DYNAMICOBJECT_SPELLID",
10 => "DYNAMICOBJECT_RADIUS",
11 => "DYNAMICOBJECT_POS_X",
12 => "DYNAMICOBJECT_POS_Y",
13 => "DYNAMICOBJECT_POS_Z",
14 => "DYNAMICOBJECT_FACING",
_ => "?",
}
}
/// Indices whose wire u32 is an f32 bit pattern.
fn dyn_field_is_f32(index: u16) -> bool {
matches!(index, 4 | 10..=14)
}
/// `UNIT_FIELD_POWER1` (mana) for 1.12.1 — `OBJECT_END(6) + 0x11` (vmangos
/// `UpdateFields_1_12_1.h:50`). The mana-ack gate below watches this on the self guid.
const FIELD_UNIT_POWER1: u16 = 6 + 0x11;
pub(crate) struct GroundFx {
spell: u32,
staged: bool,
known: bool,
/// Set when the self unit's `UNIT_FIELD_POWER1` echoes the GM fill — BOTH chat commands
/// (`.learn`, `.modify mana`) execute deferred, and each needs its own server-side evidence
/// before the cast: a cast in the learn's batch is dropped as unknown (decision 0792's trap),
/// and one in the mana fill's batch refuses with no-power (observed live, reason 77).
mana_seen: bool,
cast_sent: Option<Instant>,
/// DynamicObject creates seen: (guid, seen-at, spell id from field 9).
creates: Vec<(u64, Instant, u32)>,
removals: usize,
spell_go_seen: bool,
}
impl GroundFx {
pub(crate) fn new(spell: u32) -> Self {
Self {
spell,
staged: false,
known: false,
mana_seen: false,
cast_sent: None,
creates: Vec::new(),
removals: 0,
spell_go_seen: false,
}
}
}
impl Probe for GroundFx {
fn poll(&mut self, cx: &mut Ctx) -> Result<()> {
// Stage once the book is in: GM-learn the spell (skipped if already known — a prior run's
// leftover) and fill a mana pool any class can spend (the probe char may be a warrior).
// Both chat commands execute DEFERRED server-side, so the cast waits for the
// SMSG_LEARNED_SPELL ack (the same trap the --spells dest phase hit, decision 0792).
if !self.staged {
let Some(book) = &cx.world.spell_book else {
return Ok(());
};
if book.contains(&self.spell) {
self.known = true;
println!("groundfx: spell {} already known", self.spell);
} else {
cx.session.send_chat(&format!(".learn {}", self.spell))?;
println!("groundfx: sent .learn {}", self.spell);
}
cx.session.send_chat(".modify mana 12000 12000")?;
self.staged = true;
}
if self.known && self.mana_seen && self.cast_sent.is_none() {
let pos = cx.world.self_pose().0;
cx.world.dest_spell = Some(self.spell);
cx.session.cast_spell_at_dest(self.spell, pos)?;
println!(
"groundfx: sent CMSG_CAST_SPELL {} at own feet ({:.2}, {:.2}, {:.2}) — mask 0x40",
self.spell, pos[0], pos[1], pos[2]
);
self.cast_sent = Some(Instant::now());
}
Ok(())
}
fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> {
match ev {
SessionEvent::SpellLearned { spell_id } if *spell_id == self.spell => {
self.known = true;
}
// The mana-ack gate: the self unit's POWER1 showing a real pool — either the login
// create (a mage arrives full) or the `.modify mana` echo (the warrior's fill).
SessionEvent::ObjectCreate { guid, fields, .. }
| SessionEvent::ObjectValues { guid, fields }
if *guid == cx.world.self_guid && !self.mana_seen =>
{
if fields
.raw_fields()
.any(|(i, v)| i == FIELD_UNIT_POWER1 && v >= 500)
{
self.mana_seen = true;
println!("groundfx: self mana visible — the cast gate is open");
}
}
// vmangos sends UPDATEFLAG_HAS_POSITION on every dynobj create, so the position is
// always live here. Field 9 (SPELLID) is dumped with the rest raw.
SessionEvent::ObjectCreate {
guid,
kind: EntityKind::DynamicObject,
position,
orientation,
fields,
..
} => {
let mut raw: Vec<(u16, u32)> = fields.raw_fields().collect();
raw.sort_unstable_by_key(|(i, _)| *i);
let spell_id = raw
.iter()
.find(|(i, _)| *i == 9)
.map(|(_, v)| *v)
.unwrap_or(0);
println!(
"groundfx: DYNAMICOBJECT CREATE guid={guid:#018x} pos=({:.2}, {:.2}, {:.2}) o={orientation:.3}{}",
position[0],
position[1],
position[2],
if spell_id != 0 {
format!(" — spell {spell_id}")
} else {
String::new()
}
);
for (index, value) in &raw {
if dyn_field_is_f32(*index) {
println!(
" [{index:>3}] {:<28} = {:#010x} (f32 {})",
dyn_field_label(*index),
value,
f32::from_bits(*value)
);
} else {
println!(
" [{index:>3}] {:<28} = {:#010x} ({})",
dyn_field_label(*index),
value,
value
);
}
}
if spell_id != 0 {
self.creates.push((*guid, Instant::now(), spell_id));
}
}
SessionEvent::SpellGo {
caster,
spell_id,
hits,
misses,
target,
..
} if *spell_id == self.spell => {
self.spell_go_seen = true;
println!(
"groundfx: SPELL_GO spell {spell_id} caster={caster:#x} hits={} misses={} target={target:?} \
(NOTE: the wire's dest Vector3d is dropped by the event layer today)",
hits.len(),
misses.len()
);
}
SessionEvent::ObjectsRemoved(guids) => {
for g in guids {
if let Some((_, at, spell)) = self.creates.iter().find(|(cg, _, _)| cg == g) {
println!(
"groundfx: DYNAMICOBJECT {g:#018x} (spell {spell}) OUT-OF-RANGE — lived {:.2} s",
at.elapsed().as_secs_f32()
);
self.removals += 1;
}
}
}
SessionEvent::ObjectDestroyed(g) => {
if let Some((_, at, spell)) = self.creates.iter().find(|(cg, _, _)| cg == g) {
println!(
"groundfx: DYNAMICOBJECT {g:#018x} (spell {spell}) DESTROYED — lived {:.2} s",
at.elapsed().as_secs_f32()
);
self.removals += 1;
}
}
_ => {}
}
Ok(())
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
if self.cast_sent.is_none() {
bail!(
"groundfx: the cast was never sent (spell {} never became known — check the \
.learn ack)",
self.spell
);
}
match cx.world.dest_verdict {
None => bail!(
"groundfx: no SMSG_CAST_RESULT verdict for spell {}",
self.spell
),
Some((_, false, reason)) => bail!(
"groundfx: dest cast of {} REFUSED by the server (reason {reason:?})",
self.spell
),
Some((_, true, _)) => {}
}
println!(
"groundfx: spell {} — {} DynamicObject create(s), {} removal(s), SPELL_GO {}",
self.spell,
self.creates.len(),
self.removals,
if self.spell_go_seen {
"seen"
} else {
"NOT seen"
}
);
if self.creates.is_empty() {
println!(
"groundfx: NO DynamicObject arrived — either this spell anchors nothing (no \
persistent-area effect) or the window closed too early"
);
}
Ok(())
}
}
@@ -65,6 +65,7 @@ mod charge;
mod death;
mod equip_pack_slot;
mod giverstatus;
mod groundfx;
mod loot;
mod query_names;
mod quest;
@@ -84,6 +85,7 @@ pub(crate) use charge::Charge;
pub(crate) use death::Death;
pub(crate) use equip_pack_slot::EquipPackSlot;
pub(crate) use giverstatus::GiverStatus;
pub(crate) use groundfx::GroundFx;
pub(crate) use loot::Loot;
pub(crate) use query_names::QueryNames;
pub(crate) use quest::Quest;
@@ -9,10 +9,22 @@ use benilla_protocol::{decode, guid, EntityKind, SessionEvent};
use crate::probes::{Ctx, Probe};
/// The dest-cast phase's spell: 4054 "Rough Dynamite" — dest-targeted (`Targets = 0x40`), zero
/// mana, no reagents/totems, no equipped-item requirement, no aura state (live `spell_template`
/// sweep, decision 0792) — castable by ANY class the probe character happens to be, once GM-learnt.
const DEST_SPELL: u32 = 4054;
#[derive(Default)]
pub(crate) struct Spells {
cast_sent: Option<u32>,
targeted_cast_sent: Option<(u32, u64)>,
dest_cast_sent: Option<[f32; 3]>,
dest_learn_sent: bool,
/// Set by `SMSG_LEARNED_SPELL` naming [`DEST_SPELL`] — the gate the dest cast waits behind.
/// The `.learn` chat command executes DEFERRED on the server (past the session's opcode
/// batch), so a cast sent in the same batch beats it and is silently dropped as unknown
/// ("casts spell 4054 which he shouldn't have" — observed live, 2026-07-30).
dest_spell_known: bool,
}
impl Probe for Spells {
@@ -39,10 +51,47 @@ impl Probe for Spells {
}
}
}
// Phase 2: once the self-cast is answered, cast the same spell AT the first streamed
// creature — this exercises the mask-2 + PACKED-guid target block, the path the action
// bar uses with a selection (the self-cast only proves mask 0).
if self.targeted_cast_sent.is_none() && cx.world.cast_verdict.is_some() {
// Phase 3 (decision 0792): the GROUND cast — mask 0x40 + three f32 WoW coords, the body
// the targeting cursor's world-click commit sends. GM-learn the classless DEST_SPELL,
// wait for the server's own `SMSG_LEARNED_SPELL` ack (the chat command executes deferred
// — a cast in the same batch is dropped as unknown), then cast at our own feet
// (`self_pose` — distance 0, always in range, always LOS). Routed to `dest_verdict` by
// spell id, so it can't collide with phase 2's positional slot. Gated like phase 2 on
// the phase-1 verdict (the book/bar round trip is proven).
if self.dest_cast_sent.is_none() && cx.world.cast_verdict.is_some() {
// A prior run's deferred .learn may already have stuck — the login book then
// carries the spell and no fresh SMSG_LEARNED_SPELL will ever fire.
if !self.dest_spell_known
&& cx
.world
.spell_book
.as_ref()
.is_some_and(|b| b.contains(&DEST_SPELL))
{
self.dest_spell_known = true;
}
if !self.dest_spell_known && !self.dest_learn_sent {
cx.session.send_chat(&format!(".learn {DEST_SPELL}"))?;
println!("sent .learn {DEST_SPELL} (Rough Dynamite — the dest-cast phase's spell)");
self.dest_learn_sent = true;
}
if self.dest_spell_known {
let (pos, _) = cx.world.self_pose();
cx.world.dest_spell = Some(DEST_SPELL);
cx.session.cast_spell_at_dest(DEST_SPELL, pos)?;
println!(
"sent CMSG_CAST_SPELL for spell {DEST_SPELL} at dest ({:.2}, {:.2}, {:.2}) — mask 0x40",
pos[0], pos[1], pos[2]
);
self.dest_cast_sent = Some(pos);
}
}
// Phase 2: once the self-cast AND the ground cast are answered, cast the bar spell AT
// the first streamed creature — the mask-2 + PACKED-guid target block. Ordered AFTER the
// dest phase deliberately: `.learn` executes deferred and resolves the SELECTION, so a
// creature selected here before the command ran turned the learn into "Player not
// found!" (observed live, 2026-07-30 — the `.cheat god` re-target trap, method.md).
if self.targeted_cast_sent.is_none() && cx.world.dest_verdict.is_some() {
if let Some(spell) = self.cast_sent {
if let Some((&guid, _)) = cx
.world
@@ -60,6 +109,18 @@ impl Probe for Spells {
Ok(())
}
fn on_event(&mut self, ev: &SessionEvent, _cx: &mut Ctx) -> Result<()> {
// The dest phase's learn ack: the book already carrying the spell (a prior probe run's
// leftover) counts the same — `SMSG_INITIAL_SPELLS` is handled at the gate below instead,
// since the book arrives before the learn is ever sent.
if let SessionEvent::SpellLearned { spell_id } = ev {
if *spell_id == DEST_SPELL {
self.dest_spell_known = true;
}
}
Ok(())
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
let world = &mut *cx.world;
let session = &mut *cx.session;
@@ -182,6 +243,27 @@ impl Probe for Spells {
(Some(e), _) => bail!("no SMSG_ITEM_QUERY_SINGLE_RESPONSE for entry {e}"),
(None, _) => println!("(no item-kind button on the bar to item-query)"),
}
// --spells dest-cast verdict (decision 0792): the wire shape must not merely parse — the
// cast must be ACCEPTED. A missing verdict means the body desynced the server's reader
// (the CMSG was dropped or the session died); a refusal names the CheckCast reason.
match (self.dest_cast_sent, &world.dest_verdict) {
(Some(pos), Some((_, true, _))) => {
println!(
"✅ ground cast (mask 0x40 + dest): spell {DEST_SPELL} at ({:.2}, {:.2}, {:.2}) → ok.",
pos[0], pos[1], pos[2]
);
}
(Some(_), Some((_, false, r))) => {
bail!(
"ground cast {DEST_SPELL} REFUSED (reason {:#04x}) — the dest body parsed but CheckCast said no",
r.unwrap_or(0)
)
}
(Some(_), None) => {
bail!("no SMSG_CAST_RESULT for the GROUND cast of {DEST_SPELL} — the mask-0x40 dest body desyncs the server")
}
(None, _) => bail!("ground cast never sent (phase 1 never resolved?)"),
}
match (self.targeted_cast_sent, &world.targeted_verdict) {
(Some((spell, guid)), Some((s2, ok2, r2))) if *s2 == spell => {
println!(
@@ -74,6 +74,11 @@ pub(crate) struct World {
pub(crate) item_answer: Option<(u32, Option<String>)>,
pub(crate) cast_verdict: Option<(u32, bool, Option<u8>)>,
pub(crate) targeted_verdict: Option<(u32, bool, Option<u8>)>,
/// The `--spells` dest-cast phase's spell (decision 0792): a `SMSG_CAST_RESULT` naming it
/// routes to [`Self::dest_verdict`] instead of the positional pair above — the dest phase
/// runs concurrently with the targeted phase, so position alone can't route it.
pub(crate) dest_spell: Option<u32>,
pub(crate) dest_verdict: Option<(u32, bool, Option<u8>)>,
pub(crate) swings_seen: u32,
pub(crate) self_moves: u32,
pub(crate) tally: BTreeMap<String, u32>,
@@ -113,6 +118,8 @@ impl World {
item_answer: None,
cast_verdict: None,
targeted_verdict: None,
dest_spell: None,
dest_verdict: None,
swings_seen: 0,
self_moves: 0,
tally: BTreeMap::new(),
@@ -430,7 +437,9 @@ impl World {
reason,
} => {
println!("SMSG_CAST_RESULT: spell {spell_id} success={success} reason={reason:?}");
if self.cast_verdict.is_none() {
if self.dest_spell == Some(*spell_id) {
self.dest_verdict = Some((*spell_id, *success, *reason));
} else if self.cast_verdict.is_none() {
self.cast_verdict = Some((*spell_id, *success, *reason));
} else {
self.targeted_verdict = Some((*spell_id, *success, *reason));
+8
View File
@@ -27,6 +27,10 @@ pub enum EntityKind {
Player,
Unit,
GameObject,
/// A `TYPEID_DYNAMICOBJECT` (6) create — the invisible anchor a ground-targeted spell's
/// area effect hangs on (Blizzard's storm, Flamestrike's burn). Carries no display id; its
/// visual resolves through the spell chain (`DYNAMICOBJECT_SPELLID`), not a model field.
DynamicObject,
Other,
}
@@ -512,6 +516,10 @@ pub enum SessionEvent {
/// The GameObject an open-lock cast launched at (`TARGET_FLAG_GAMEOBJECT`) — opens a chest lid /
/// locked door (decision 0250). `None` for a unit spell.
go_target: Option<u64>,
/// The ground point a dest-targeted cast launched at (`TARGET_FLAG_DEST_LOCATION`), raw
/// WoW coords — where a ground AOE's launch-side visual belongs (the B132 follow-up;
/// the persistent effect anchors to the DynamicObject create instead). `None` otherwise.
dest: Option<[f32; 3]>,
ammo_display_id: Option<u32>,
/// The **cast item's** guid when the packet's first guid names one (an item use — potion,
/// scroll: `item_or_caster != caster`); `None` for a plain spell cast. The item-use
@@ -152,6 +152,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
misses: s.misses,
target: s.targets.unit_target,
go_target: s.targets.go_target,
dest: s.targets.dest.map(|d| [d.x, d.y, d.z]),
ammo_display_id: s.ammo_display_id,
item_caster: (s.item_or_caster != s.caster).then_some(s.item_or_caster),
}],
@@ -823,6 +824,7 @@ fn entity_kind(t: ObjectType) -> EntityKind {
ObjectType::Player => EntityKind::Player,
ObjectType::Unit => EntityKind::Unit,
ObjectType::GameObject => EntityKind::GameObject,
ObjectType::DynamicObject => EntityKind::DynamicObject,
_ => EntityKind::Other,
}
}
+2 -2
View File
@@ -147,8 +147,8 @@ pub use social::{
};
pub use spellbook::SpellCooldown;
pub use spells::{
cancel_aura, cast_spell, cast_spell_gameobject, cast_spell_item, CastOutcome, SpellCastTargets,
SpellGo, SpellStart,
cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_gameobject, cast_spell_item,
CastOutcome, SpellCastTargets, SpellGo, SpellStart,
};
pub use taxi::{
activate_taxi, activate_taxi_express, taxi_node_status_query, taxi_query_available_nodes,
@@ -353,6 +353,22 @@ pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec<u8> {
body
}
/// Body of `CMSG_CAST_SPELL` aimed at a **ground point** (decision 0792): the targeting-cursor
/// commit for a `Targets & 0x40` spell (Blizzard, Flamestrike, Rain of Fire…). `spell_id`, mask
/// `DEST_LOCATION (0x0040)`, then the destination as three `f32` **WoW world coords** — the one
/// field the mask reads (vmangos `SpellCastTargets::read`, `SpellCastTargetsInfo.cpp:169-174`:
/// `DEST_LOCATION → x,y,z`, `IsValidMapCoord`-gated). The real client ships the same shape from
/// `SPELLCAST+0x3c..0x44` (`BindLocation 0x6e60f0` → `SendCast 0x6e54f0`, wow-re `wave-cast.md`).
pub fn cast_spell_at_dest(spell_id: u32, dest: [f32; 3]) -> Vec<u8> {
let mut body = Vec::with_capacity(18);
body.extend_from_slice(&spell_id.to_le_bytes());
body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes());
for c in dest {
body.extend_from_slice(&c.to_le_bytes());
}
body
}
/// Body of `CMSG_CANCEL_AURA` (vmangos `WorldPackets::Spell::CancelAura`, `Server/Packets/Spell.h:55-62`):
/// one `u32` spell id. The server cancels **by spell, not by slot** — `HandleCancelAuraOpcode`
/// (`SpellHandler.cpp:333-405`) looks the spell up, refuses passives, `SPELL_ATTR_NO_AURA_CANCEL`
@@ -380,6 +396,24 @@ mod tests {
assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
}
#[test]
fn cast_spell_at_dest_body_golden() {
// spell_id 10 (Blizzard, LE) + mask DEST_LOCATION 0x0040 (LE `40 00`) + the dest Vec3 as
// three f32 LE: 1.0 = 00 00 80 3F, -2.5 = 00 00 20 C0, 3.0 = 00 00 40 40. VERIFIED against
// vmangos `SpellCastTargets::read` (mask → DEST branch reads exactly x, y, z).
assert_eq!(
cast_spell_at_dest(10, [1.0, -2.5, 3.0]),
[
0x0A, 0x00, 0x00, 0x00, // spell id
0x40, 0x00, // TARGET_FLAG_DEST_LOCATION
0x00, 0x00, 0x80, 0x3F, // x = 1.0
0x00, 0x00, 0x20, 0xC0, // y = -2.5
0x00, 0x00, 0x40, 0x40, // z = 3.0
],
"CMSG_CAST_SPELL (ground dest) body"
);
}
#[test]
fn aura_bodies_golden() {
// CMSG_CANCEL_AURA: a lone u32 spell id, LE. 1126 (Mark of the Wild) = 0x0000_0466. The
@@ -472,6 +472,39 @@ impl ObjectFields {
self.get_guid(6).filter(|&g| g != 0)
}
/// `DYNAMICOBJECT_CASTER` (fields 6–7, `OBJECT_END + 0x0`, a 2-field guid — vmangos
/// `UpdateFields_1_12_1.h:325`): the unit whose ground cast anchored this object.
/// Index-twin of [`Self::corpse_owner`] — each TYPEID reuses `OBJECT_END`; the TypeId (or
/// [`Self::dynamicobject_spell_id`]'s presence) decides which name applies.
pub fn dynamicobject_caster(&self) -> Option<u64> {
self.get_guid(6).filter(|&g| g != 0)
}
/// `DYNAMICOBJECT_BYTES` (field 8): the dynobj type byte — vmangos sends `1` (area spell)
/// for every persistent-area cast (`DynamicObject::Create`; captured live 2026-07-30 for
/// Blizzard 10 and Flamestrike 2120).
pub fn dynamicobject_bytes(&self) -> Option<u32> {
self.get_u32(8)
}
/// `DYNAMICOBJECT_SPELLID` (field 9): the anchoring spell — the root of the dest-anchored
/// visual chain (B132 follow-up). `None`/0 when absent.
pub fn dynamicobject_spell_id(&self) -> Option<u32> {
self.get_u32(9).filter(|&s| s != 0)
}
/// `DYNAMICOBJECT_RADIUS` (field 10): the area's radius in yards (live: Blizzard 8.0,
/// Flamestrike 5.0 — the server's `SpellRadius.dbc` resolve).
pub fn dynamicobject_radius(&self) -> Option<f32> {
self.get_f32(10)
}
/// `DYNAMICOBJECT_POS_X/Y/Z` + `FACING` (fields 11–14): the anchored point in raw WoW
/// coords. Duplicates the create's movement-block position on the vmangos wire (both
/// captured identical live); the accessor exists for `Values`-delta reads and tests.
pub fn dynamicobject_position(&self) -> Option<([f32; 3], f32)> {
Some((
[self.get_f32(11)?, self.get_f32(12)?, self.get_f32(13)?],
self.get_f32(14).unwrap_or(0.0),
))
}
fn get_guid(&self, index: u16) -> Option<u64> {
// Raw map read on purpose: a guid slot stays "present iff the low half is" even on a
// created store — every consumer already treats a sent-empty `Some(0)` as no-guid, so the
@@ -402,3 +402,37 @@ fn merge_overlays_deltas_and_replaces_on_recreate() {
"a re-create replaces — the stale 30 must not survive an overlay"
);
}
/// The `DYNAMICOBJECT_*` accessors against the exact live capture (vmangos, 2026-07-30, the
/// B132 follow-up's `--groundfx 10` run): Blizzard's dynobj create — caster guid 26, BYTES 1
/// (area spell), SPELLID 10, RADIUS 8.0, the cast point in POS, FACING never sent.
#[test]
fn dynamicobject_fields_read_the_live_blizzard_capture() {
let f = ObjectFields::from_pairs(&[
(0, 6), // OBJECT_FIELD_GUID lo
(1, 0xf100_0000), // guid hi — HIGHGUID dynobj
(2, 0x41), // TYPE: OBJECT | DYNAMICOBJECT
(3, 10), // ENTRY = the spell id
(4, 1.0f32.to_bits()), // SCALE_X
(6, 26), // DYNAMICOBJECT_CASTER lo
(8, 1), // BYTES: area spell
(9, 10), // SPELLID
(10, 8.0f32.to_bits()), // RADIUS
(11, (-8844.14f32).to_bits()), // POS_X
(12, 668.83f32.to_bits()), // POS_Y
(13, 97.81f32.to_bits()), // POS_Z
])
.into_created();
assert_eq!(f.dynamicobject_caster(), Some(26));
assert_eq!(f.dynamicobject_bytes(), Some(1));
assert_eq!(f.dynamicobject_spell_id(), Some(10));
assert_eq!(f.dynamicobject_radius(), Some(8.0));
let (pos, facing) = f.dynamicobject_position().unwrap();
assert_eq!(pos, [-8844.14, 668.83, 97.81]);
assert_eq!(facing, 0.0, "FACING absent on a created store reads 0");
// Not a dynobj (a corpse never streams SPELLID): the id gate reads None, not Some(0).
assert_eq!(
ObjectFields::from_pairs(&[(6, 26)]).dynamicobject_spell_id(),
None
);
}
@@ -321,6 +321,16 @@ impl WorldSession {
)
}
/// Cast a spell at a **ground point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION` +
/// WoW world coords) — the unsplit twin of [`WorldWriter::cast_spell_at_dest`], for the
/// `--spells` probe's dest-cast round trip (decision 0792).
pub fn cast_spell_at_dest(&mut self, spell_id: u32, dest: [f32; 3]) -> Result<()> {
self.send(
opcode::CMSG_CAST_SPELL,
&messages::cast_spell_at_dest(spell_id, dest),
)
}
/// Cast an OPEN_LOCK spell at a **GameObject** (`CMSG_CAST_SPELL`) — the unsplit twin of
/// [`WorldWriter::cast_spell_gameobject`], for a probe's live chest-loot verification (decision
/// 0239): cast e.g. 3365 "Opening" / 2575 "Mining" at a lockable GO and expect
@@ -60,6 +60,17 @@ impl WorldWriter {
)
}
/// Cast a spell at a **ground point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION` +
/// the destination Vec3 in WoW world coords — [`messages::cast_spell_at_dest`]): the
/// targeting-cursor commit for a ground-targeted AOE (decision 0792). The server range/LOS
/// checks the point in `Spell::CheckCast`; refusal answers `SMSG_CAST_RESULT`.
pub fn cast_spell_at_dest(&mut self, spell_id: u32, dest: [f32; 3]) -> Result<()> {
self.send(
opcode::CMSG_CAST_SPELL,
&messages::cast_spell_at_dest(spell_id, dest),
)
}
/// Cancel a named in-flight cast (`CMSG_CANCEL_CAST`: one `u32` spell id — vmangos
/// `HandleCancelCastOpcode`). Sent by the wand-only auto-repeat handoff (`0x6095b8`) and by
/// the cast bar's local self-cancel (movement/Esc mid-cast, `benilla::ui_cast`).
+47
View File
@@ -370,6 +370,7 @@ fn spell_visual_wire_golden() {
misses,
target,
go_target,
dest,
ammo_display_id,
item_caster,
} => {
@@ -380,6 +381,7 @@ fn spell_visual_wire_golden() {
assert_eq!(misses, vec![(0xCC, 2), (0xDD, 11)]);
assert_eq!(target, Some(0xEE));
assert_eq!(go_target, None); // a unit-targeted cast leaves the GO target empty
assert_eq!(dest, None); // …and the ground point empty (mask 0x2, no 0x40)
assert_eq!(ammo_display_id, Some(5996));
// The packet's first guid (1) differs from the caster — an item cast, surfaced for
// the item-use cooldown key.
@@ -388,6 +390,51 @@ fn spell_visual_wire_golden() {
other => panic!("spell go event, got {other:?}"),
}
// SMSG_SPELL_GO for a GROUND cast (the B132 follow-up): empty hit/miss lists, mask 0x0040 +
// the dest Vector3d — the exact shape the live vmangos Blizzard capture produced
// (2026-07-30; the f32 bit patterns below are the captured DYNAMICOBJECT_POS values). The
// event layer must surface the point — it is the only launch-side record of where the
// spell went.
let body = hx(concat!(
"011a", // item_or_caster pguid: guid 0x1a (== caster: a plain spell, no item)
"011a", // caster pguid
"0a000000", // spellId 10 (Blizzard)
"0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9)
"00", // hit count 0
"00", // miss count 0
"4000", // SpellCastTargets mask: TARGET_FLAG_DEST_LOCATION
"8f300ac6", // dest x −8844.14 (0xc60a308f)
"1f352744", // dest y 668.83 (0x4427351f)
"b89ec342", // dest z 97.81 (0x42c39eb8)
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::SpellGo {
caster,
spell_id,
hits,
misses,
target,
dest,
item_caster,
..
} => {
assert_eq!((caster, spell_id), (0x1a, 10));
assert!(hits.is_empty() && misses.is_empty());
assert_eq!(target, None);
assert_eq!(
dest,
Some([
f32::from_bits(0xc60a308f),
f32::from_bits(0x4427351f),
f32::from_bits(0x42c39eb8),
])
);
assert_eq!(item_caster, None, "same guid twice — not an item cast");
}
other => panic!("ground spell go event, got {other:?}"),
}
// SMSG_SPELL_FAILED_OTHER: raw (unpacked) guid + spellId (vmangos `Spell::SendInterrupted`).
let body = hx(concat!("4200000000000000", "85000000"));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_FAILED_OTHER, &body).unwrap();
+11
View File
@@ -234,6 +234,15 @@ pub(crate) struct Model {
/// Set when `SpellStopCasting()` fired while [`Self::casting`] — the ESC local-cancel
/// trigger, drained by [`super::UiScript::take_spell_stop`] ([`spellbook`]).
pub(crate) spell_stop: bool,
/// Whether the app's spell-targeting cursor mode is active — the `flag_word != 0` mirror
/// (`SpellIsTargeting 0x6e6cd0`, decision 0792). Pushed each frame by the app's targeting
/// feed ([`super::UiScript::set_spell_targeting`]); read by `SpellIsTargeting()` and gating
/// `SpellStopTargeting()`, whose 1/nil return the ESC chain's rung (`UIParent.lua:1490`)
/// falls through on, exactly like [`Self::casting`]'s.
pub(crate) spell_targeting: bool,
/// Set when `SpellStopTargeting()` fired while [`Self::spell_targeting`] — the ESC-chain
/// targeting cancel, drained by [`super::UiScript::take_stop_targeting`] ([`spellbook`]).
pub(crate) spell_stop_targeting: bool,
/// The player's talent pages (decision 0304) — tabs + per-tab talents the
/// `GetNumTalentTabs`/`GetTalentInfo`/… bindings read ([`super::talent`]). Durable player
@@ -698,6 +707,8 @@ impl Model {
spell_casts: Vec::new(),
casting: false,
spell_stop: false,
spell_targeting: false,
spell_stop_targeting: false,
talents: super::talent::TalentUiState::default(),
talent_learns: Vec::new(),
shapeshift_forms: Vec::new(),
+47
View File
@@ -112,6 +112,19 @@ impl super::UiScript {
pub fn take_spell_stop(&mut self) -> bool {
std::mem::take(&mut self.model_mut().spell_stop)
}
/// Push whether the app's spell-targeting cursor mode is active (decision 0792) — what
/// `SpellIsTargeting()` reads and `SpellStopTargeting()` gates on. Pushed each frame by the
/// app's targeting feed (`benilla::ui_action`), before the input pass runs the ESC chain.
pub fn set_spell_targeting(&mut self, targeting: bool) {
self.model_mut().spell_targeting = targeting;
}
/// Drain the `SpellStopTargeting()` trigger: `true` if it fired while targeting since the
/// last call — the ESC-chain rung (`UIParent.lua:1490`); the app clears its targeting mode.
pub fn take_stop_targeting(&mut self) -> bool {
std::mem::take(&mut self.model_mut().spell_stop_targeting)
}
}
/// The book-id → 0-based [`SpellBookState::slots`] index seam (module docs): `None` for a
@@ -284,6 +297,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SpellIsTargeting() — the ref's Script::SpellIsTargeting (`0x6e6cd0`, wow-re
// `wave-cast.md`): true while the targeting cursor is up (`flag_word != 0`), nil otherwise.
// Read by FrameXML (PetFrame's right-click bind fork) and by the ESC chain's callers.
g.set(
"SpellIsTargeting",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.spell_targeting {
Ok(Value::Boolean(true))
} else {
Ok(Value::Nil)
}
})?,
)?;
// SpellStopTargeting() — the ref's Script::SpellStopTargeting (`0x6e6e30`: if IsTargeting →
// StopTargeting `0x6e4900` → AbortCast(0x1c), which in targeting mode just clears the word,
// 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.
g.set(
"SpellStopTargeting",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.spell_targeting {
model.spell_stop_targeting = true;
Ok(Value::Integer(1))
} else {
Ok(Value::Nil)
}
})?,
)?;
Ok(())
}
+111 -41
View File
@@ -17,13 +17,21 @@
// detail clutter, the merged mesh ships `ATTRIBUTE_COLOR = (mcsh_tint, mcsh_tint, mcsh_tint, 1)`,
// which Bevy folds into base_color → the lit factor is multiplied by the MCSH grey per-doodad.
//
// Models (M2 doodads/creatures/player) are lit by the SAME faithful fixed-function directional matte as
// Models (M2 doodads/creatures/player) are lit here by the same fixed-function directional matte as
// terrain and WMO — `clamp(ambient + diffuse·max(N·L,0))` on the model's own vertex normal, to-light =
// `−light_sun.xyz` (GL_LIGHTING + GL_LIGHT0 + GL_COLOR_MATERIAL, byte-verified off WoW.exe 5875). The one
// difference for exterior WORLD doodads: the sun/diffuse term is scaled by the per-instance TERRAIN-SHADE
// sampled at the doodad's base (2.5 on lit ground, 0.5 on MCSH-shadowed ground — verified `[def+0xa4]`,
// ratio 0.2 load-bearing; ambient stays full). There is NO M2 irradiance lobe: the earlier `model_lobe`
// order-2 curve was a fabrication (no such program runs — M2UsePixelShaders defaults off), now removed.
// `−light_sun.xyz`. The one difference for exterior WORLD doodads: the sun/diffuse term is scaled by the
// per-instance TERRAIN-SHADE sampled at the doodad's base (2.5 on lit ground, 0.5 on MCSH-shadowed
// ground — verified `[def+0xa4]`, ratio 0.2 load-bearing; ambient stays full).
//
// **On the exterior M2 lane that matte is OUR choice, not the reference's response** — see 0796 and the
// lane comment at step 7. Terrain and WMO genuinely are fixed-function (GL_LIGHTING + GL_LIGHT0 +
// GL_COLOR_MATERIAL, byte-verified off WoW.exe 5875, and all five FFP light-commit call sites are
// terrain's); M2 is not. An earlier version of this header claimed "there is NO M2 irradiance lobe … no
// such program runs — M2UsePixelShaders defaults off". That is wrong twice over: the gate is the VERTEX
// cvar `M2UseShaders`, which defaults to "1", and the program it loads (`Shaders\Vertex\Model2.bls`, out
// of misc.MPQ) IS an order-2 irradiance lobe, running on every exterior M2 the reference draws. The claim
// is corrected rather than deleted — it stood long enough to seed a decision record, and the next reader
// should know it was retired, not that it was never made.
//
// Specular (row 9 separate-specular, local viewer) is verified by q4/q5/Q13 but kept OUT of this
// step — M2 per-material shininess (q4 §6 INFERRED) is its own A/B and lives in Step 7b. WMO
@@ -58,7 +66,7 @@ struct ModelParams {
clutter_fade: vec4<f32>,
model_flags: vec4<f32>,
// x = per-material MCSH terrain-shade SELECTOR (≥0.5 ⇒ lit ground, <0.5 ⇒ MCSH-shadowed); the shader
// thresholds it to pick the live lit/shaded doodad sun LEVEL from `sh_c16.x`/`.z`. yzw reserved.
// thresholds it into the lit/shaded doodad sun INTENSITY family below. yzw reserved.
sun_scale: vec4<f32>,
// xyz = the M2Color RGB tint for batches whose colour track ANIMATES (the static vertex bake is
// skipped for those — WowModelExt::tint): folded into the albedo exactly where the vertex tint
@@ -80,11 +88,10 @@ struct ModelParams {
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun for the WMO/clutter matte) + fog + the
// LIVE global SH rows 6-12 — the disassembled `Model2.bls` probe of the day/night scene light, packed
// at intensity 1 (decision 0354: the exterior M2 lane evaluates it per fragment, scaled by the
// per-instance INTENSITY — the byte-verified `[node+0xa4]`, 2.5 lit / 0.5 MCSH-shadowed / 1.0
// day-night). Shared with terrain.wgsl — a tree and the dirt under it fade into the same haze.
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
// `Model2.bls` probe of the day/night scene light at intensity 1 — the reference's own exterior M2
// response, read by the doodad/entity lane under the `WOW_M2_SH=1` A/B (0796 §5) and dark otherwise.
// Shared with terrain.wgsl — a tree and the dirt under it fade into the same haze.
// (light_spec is part of the prefix layout but the model path never reads it.)
struct WowLight {
light_ambient: vec4<f32>, // rgb ambient; w = Mod2x scale
@@ -93,26 +100,32 @@ struct WowLight {
light_spec: vec4<f32>, // rgb spec color; w = shininess (terrain's — unused by the matte model path)
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
// The global Model2.bls SH rows (6-12), LIVE (decision 0354): the scene day/night light as an
// order-2 probe at intensity 1 — DC (ambient, `.w` of c10) + the sun's linear/quad bands in
// c10.xyz / c13 / c16.xyz, the disassembled closed form (wow-re model2-bls-vertex-sh.md).
// Every sun band is linear in the committed colour, so the exterior M2 lane scales them ALL by
// the per-instance intensity (never I²); the sun's DC redistribution rides `grade.yzw`
// (× intensity). Storm/night blends flow through automatically — the pack reads the blended
// scene rows.
// The global Model2.bls SH rows (6-12): the scene day/night light as an order-2 probe at
// intensity 1 — DC (ambient, `.w` of c10) + the sun's linear/quad bands in c10.xyz / c13 /
// c16.xyz, the disassembled closed form (wow-re model2-bls-vertex-sh.md). Every sun band is
// linear in the committed colour, so a consumer scales them ALL by the per-instance intensity
// (never I²); the sun's DC redistribution rides `grade.yzw` (× intensity).
//
// **LIVE only under the response A/B (`WOW_M2_SH=1`; 0796 §5).** 0410 took the exterior M2 lane
// off this curve onto the hard-cutoff FFP matte on the director's look call, and the block sat
// packed-but-unread until that call was reopened: 0796 REFUTED the fidelity premise behind
// retiring it (the reference's M2 lane IS this SH shader, not an FFP light slot), so the cutoff
// now stands purely as a look call and the two need to be seen side by side. `sh_c16.w` carries
// the switch (it had been free since the 0273 point gain and the 0750/0751 sun dial retired).
// Whichever side the director calls, the loser goes — the eval below, or this block plus
// `pack_model_core_rows`' fold and the four struct mirrors that carry it for layout.
sh_c10_r: vec4<f32>,
sh_c10_g: vec4<f32>,
sh_c10_b: vec4<f32>,
sh_c13_r: vec4<f32>,
sh_c13_g: vec4<f32>,
sh_c13_b: vec4<f32>,
// xyz = the true c16 quad band (x²−y², per channel); w = a free lane (the eval never reads
// a c16.w; the retired point-light gain dial rode it, then the 0750/0751 sun calibration
// dial, retired by the 0753 trace law).
// xyz = the true c16 quad band (x²−y², per channel), live with the block above; w = the exterior
// M2 response A/B (0796 §5): >0.5 ⇒ the SH lobe, else the shipped hard cutoff.
sh_c16: vec4<f32>,
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by models.
// x = SIDN night fraction (1 overnight, 0 by day — scales m.sidn.rgb).
// yzw = the sun's SH DC redistribution per channel, at intensity 1 (the exterior lane × I).
// yzw = the sun's SH DC redistribution per channel, at intensity 1 (× the per-instance I).
grade: vec4<f32>,
// Rows 18-19: the INTERIOR fog triple — the 4 s camera-in-WMO MFOG crossfade (== the scene fog
// outdoors). Consumed by the interior lanes only (round-6 Q-I): interior WMO-group surfaces
@@ -528,36 +541,93 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
let n1 = vec4<f32>(n_lit, 1.0);
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
// Exterior world doodad/entity: the TRACE-VERIFIED committed-sun law (decision 0753).
// The CPU light animator targets the per-instance intensity `[node+0xa4]` — 2.5 on lit
// ground / 0.5 on MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`,
// `0x69e280`; the force-1.0 leg is `0x69e36b`) — but the reference AS THE DIRECTOR RUNS IT
// (WoW.exe D3D → wined3d; apitrace of their own client, 2026-07-29) commits the sun to the
// device at `D · min(I, 1)`: the lit 2.5 rung arrives at ×1, the shadowed rung at ×0.5, hue
// exact, never over-gamut (the Silverpine direct commits with max channel 254/255 — both
// peak-normalization and the raw ×2.5 product are refuted by that one byte). The light
// reaches the GPU as an FFP light slot (no SH constants anywhere in the trace), so the
// response is the FFP `max(N·L, 0)` — 0747's shader-lane f(μ) curve is superseded on this
// lane, and 0706's "the reference runs the shader path" premise is refuted for the
// reference-as-run. Lane history: `Model2.bls` SH lobe (0354/0358) → FFP matte, hard cutoff
// (director's call: the shadow side sits exactly at ambient) → unclamped source (0706) →
// verified curve (0747) → peak-norm (0750) → calibration dial (0751) → this trace law
// (0753), which retires the curve, the peak-norm and the dial here.
// Exterior world doodad/entity: a hard-cutoff FFP matte — `clamp01(ambient + D·min(I,1)·
// max(N·L,0))`. **A DELIBERATE DEVIATION, not the reference's response** (0796; read that
// record before touching this lane).
//
// 0410 put the hard cutoff here on the director's look call — a sunlit character's shadow side
// must read the SAME as the same skin standing in shade, and the SH lobe's authored wrap
// (0.059·C on the anti-sun side, dipping −0.037·C mid-back) tinted it warm and lifted it.
// 0753 then read the reference's own apitrace as CORROBORATING that shape (an FFP light slot,
// "no SH constants anywhere in the trace") and set the `min(I,1)` commit. **0796 refutes the
// fidelity half of 0753**: wow-re's §5 cross-check shows the reference's M2 lane — ADT doodad,
// GameObject, creature, player alike — is its own authored `Model2.bls` vertex shader running
// order-2 SH, and the FFP light commits visible in a world frame belong to TERRAIN
// (`0x71c730`'s five `0x68xxxx` call sites) and the WMO path. M2's single FFP call site
// (`70bdf6`) is reachable only with `M2UseShaders` off. "No SH constants" was a measurement
// artifact — constants upload as dirty-range deltas whose start register varies.
//
// So the reference's response on this lane is `E = A + C·(4/17)(0.375 + 2μ + 1.875μ²)`,
// μ = N·u, `clamp01` on the SUM, with the per-instance gain UNCLAMPED (linear-band gains
// observed live at 0.5/0.85/1.0/1.874/2.5). What survives here is 0410's look call alone.
// The curve is still packed and dormant in rows 6-12 (see WowLight) — one predicate away.
//
// The CPU light animator targets the per-instance intensity `[node+0xa4]` — 2.5 on lit ground
// / 0.5 on MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, `0x69e280`;
// the force-1.0 leg is `0x69e36b`). Lane history: `Model2.bls` SH lobe (0354/0358) → FFP
// matte, hard cutoff (0410, director's call) → unclamped source (0706) → verified curve
// (0747) → peak-norm (0750) → calibration dial (0751) → the 0753 trace law → 0796, which
// keeps the pixels and demotes the justification to a deviation.
//
// The per-material `sun_scale.x` selector has THREE states (model_render::ShadeSel): ≥0.85 =
// the lit-ground family (ADT doodads and every entity M2 — animator target 2.5, mixed toward
// 0.5 by the per-instance tag shade byte, which units/players/GameObjects ramp CPU-side like
// the binary's `0x69e770`; statics leave it 0), 0.5..0.85 = fixed intensity 1.0 (an exterior
// WMO MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically
// MCSH-shadowed (0.5). The ramp runs in animator units and the COMMIT clamps, like the
// reference: a lit↔shadowed transition holds ×1 until the ramp crosses 1.0.
// MCSH-shadowed (0.5). The ramp runs in animator units and the COMMIT clamps.
//
// **OPEN (0796): putting entity M2 in the lit-ground family is contradicted by observation.**
// A player + three NPCs across two of the reference's own apitraces all commit their sun at
// gain EXACTLY 1.0 — never 2.5, never 0.5 — while the same frames' ADT doodads show the full
// 0.5/0.85/1.0/1.874/2.5 spread (wow-re `trace-forensics-northshire-d3d` §5). That signature
// is `0x672a20`'s null-node fallback, i.e. a unit may not sit on the terrain-shade chain at
// all. The byte chain benilla built this on (`m2-interior-doodad-base-light` §9) is verified
// up to its FINAL link — "the unit's own node fills the unit's light" — which is the INFERRED
// one the traces contradict; wow-re has it as an open follow-up. It is NOT acted on here
// because `min(I,1)` below already lands a lit-ground unit on 1.0, so the visible error is
// confined to a unit on MCSH-shadowed ground (we dim to 0.5, the reference observes 1.0), and
// the honest fix deletes a byte-verified subsystem (0173/0354/0477/0480) on a mechanism wow-re
// has not closed. Do not "fix" it by unclamping — that lands every character at ×2.5.
let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop);
let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5);
let shade_t = max(mat_shade, inst_shade);
let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85;
let intensity = min(select(mix(2.5, 0.5, shade_t), 1.0, mid_band), 1.0);
let lit_ffp = wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * (intensity * ndotl);
// THE A/B (`WOW_M2_SH=1` → row 12 `.w`; 0796 §5) — the reference's OWN response on this lane,
// the order-2 `Model2.bls` lobe over the dormant SH rows, restored beside the cutoff so the
// director can judge the two by eye. Same closed form the interior-prop lane below runs and the
// same one `pack_model_core_rows` folds: `E = A + C·(4/17)(0.375 + 2μ + 1.875μ²)`, μ = N·u.
// Every sun band is linear in the committed colour, so ONE `intensity` multiply covers the whole
// lobe (never I²); ambient rides the c10 `.w` lanes and does NOT scale with it, and the sun's DC
// redistribution rides `grade.yzw`.
//
// `intensity` is shared with the FFP branch ON PURPOSE, so the A/B has exactly one variable —
// the curve. It keeps `min(I,1)`, which is refuted as fidelity (0796 §1) but happens to give a
// character in sunlight the ×1.0 the reference is observed to give it — i.e. the amplitude for
// the case 0410 actually judged. The known cost: an ADT doodad on lit ground sits at ×1.0 where
// the reference gives it ×2.5, so DOODADS read dimmer than the reference under either branch.
// Unclamping needs 0796 §4 (units) settled first or every character lands at ×2.5.
let sun_dc = wow_light.grade.yzw * intensity;
let lit_sh = vec3<f32>(
wow_light.sh_c10_r.w + sun_dc.x
+ intensity
* (dot(wow_light.sh_c10_r.xyz, n_lit) + dot(wow_light.sh_c13_r, quad)
+ wow_light.sh_c16.x * x2y2),
wow_light.sh_c10_g.w + sun_dc.y
+ intensity
* (dot(wow_light.sh_c10_g.xyz, n_lit) + dot(wow_light.sh_c13_g, quad)
+ wow_light.sh_c16.y * x2y2),
wow_light.sh_c10_b.w + sun_dc.z
+ intensity
* (dot(wow_light.sh_c10_b.xyz, n_lit) + dot(wow_light.sh_c13_b, quad)
+ wow_light.sh_c16.z * x2y2),
);
// clamp01 the SUM, never per term — the lobe dips to −0.037·C around μ≈−0.53 (low-order-SH
// ringing, the reference's own authored response), and clamping the sun term alone would floor
// that away and lose the mid-back dip the whole A/B is partly about.
let lit_doodad = clamp(
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * (intensity * ndotl),
select(lit_ffp, lit_sh, wow_light.sh_c16.w > 0.5),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
+8 -2
View File
@@ -496,8 +496,7 @@
-- else claims it; and only a press with NOTHING left to eat opens the menu.
--
-- Absent branches, and why: no OptionsFrame (nothing to cancel — GameMenuFrame.xml's five
-- disabled buttons are exactly that gap), and no SpellStopTargeting (no ground-target
-- cursor mode yet). ONE benilla ADDITION, ahead of everything: a held cursor item is
-- disabled buttons are exactly that gap). ONE benilla ADDITION, ahead of everything: a held cursor item is
-- dropped (ClearCursor). The cursor-item model (decision 0068 T2) has no real-client ESC
-- handling to mirror — vanilla leaves the item on the cursor — so this is a deliberate
-- benilla convenience (ESC as the universal "get me out" key). Guarded so it stays inert
@@ -589,6 +588,13 @@
if SpellStopCasting() then
return
end
-- The ref chain's SpellStopTargeting leg (UIParent.lua l.1490, decision 0792): ESC
-- with the ground-targeting cursor up cancels the targeting — word cleared, no
-- packet — and eats the press. After the cast rung, exactly the artifact's order
-- (while merely targeting nothing is in flight, so SpellStopCasting answered nil).
if SpellStopTargeting() then
return
end
-- CloseAllWindows eats the press when it closed anything (l.1491); only a press with
-- nothing left to close reaches ClearTarget (l.1492) — the ref's last eater, and the
-- ONLY route to the target drop: the raw-key Esc clear the app used to run beside
+19 -7
View File
@@ -64,11 +64,23 @@ use crate::schedule::WorldStage;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
/// The shadow's ladder rung — sort bias AND rasterizer depth-bias constant (the coplanarity fix,
/// same mechanism as the ring's `RING_DEPTH_BIAS`; the reference's own knob is the `shadowBias`
/// cvar, default 0.1 → a polygon offset). Half the ring's, so where both decals stack the ring
/// draws later deterministically.
const SHADOW_DEPTH_BIAS: f32 = 4096.0;
/// The shadow's sort-ladder rung: half the ring's `RING_DEPTH_BIAS`, so where both decals stack
/// the ring draws later deterministically. Sort only — the rasterizer margin is
/// [`SHADOW_RASTER_BIAS`], sized independently (B131 split one constant into its two roles).
const SHADOW_SORT_BIAS: f32 = 4096.0;
/// The shadow's rasterizer depth-bias constant — the margin funding the `GreaterEqual` tie
/// against the drawn ground (`DECAL_WORLD_CLIP`, 0781). The reference's mechanism is the same
/// class: `shadowBias` cvar 0.1 → a **constant-only** polygon offset (−102.4 LSBs of its 24-bit
/// buffer; no slope term exists in the binary — wow-re unit-blob-shadow RE, corroborated across
/// every shadow draw of four apitraces). The *size* is ours, not the reference's number: our
/// residual is 0781's — the decal's CPU-baked world verts vs the receiver's GPU-transformed
/// verts diverge by ~1–3 ulps of the world coordinate, which is millimetres at city magnitudes
/// and lands on the depth tie where the receiver is *sloped* (horizontal ulps project onto the
/// tilted normal — the confirmed B131 flicker walk was the Stormwind gate ramp). 4096 absorbed
/// under half the 3-ulp worst case at close zoom and lost the tie while moving; 32768 dominates
/// it ≥2× at every zoom ≥1 yd and stays centimetre-order at 30 yd (no visible punch-through).
/// Sizing pinned by `raised_bias_dominates_the_bake_residual` (particles/render.rs).
pub(crate) const SHADOW_RASTER_BIAS: i32 = 32768;
/// The byte clamp on the animation box: each corner component is clamped INTO ±5 yd pre-scale
/// (`0x6992c0` MAX(−5) / `0x699250` MIN(+5) — a cap on huge authored boxes, never a floor).
const BOX_CLAMP: f32 = 5.0;
@@ -475,8 +487,8 @@ fn push_shadows(
blend: EffectBlend::Multiply,
fog: EffectFog::Off,
anchor: key.feet,
bias: SHADOW_DEPTH_BIAS,
raster_bias: SHADOW_DEPTH_BIAS as i32,
bias: SHADOW_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
main_entity: entity,
light: None,
},
+4 -2
View File
@@ -576,8 +576,10 @@ fn create_result(
char_result_text(msg.code)
);
if msg.code == benilla_protocol::messages::CHAR_CREATE_SUCCESS {
// The fresh roster already arrived (phase 1 emits it before the result); arm the new row
// and return to select.
// The fresh roster already arrived (`net::io` re-enumerates and emits it BEFORE the
// result), so `note_created` selects the new row against the list already in hand —
// arming a flag for "the next roster update" waited for a message that never comes
// again, and the select screen came back on the old row (B119).
roster.note_created(sel.name.text.clone());
next.set(ClientState::CharSelect);
} else if let Ok(mut text) = status.single_mut() {
+147 -8
View File
@@ -121,8 +121,10 @@ pub(crate) struct Roster {
/// `WOW_CHAR`, when explicitly set: auto-pick this name on the FIRST roster (the dev fast
/// path past the screen). `take()`n once — a later `/logout` shows the screen normally.
env_char: Option<String>,
/// A just-created character's name (decision 0423): the next roster update selects its row
/// (the ref's `SELECT_LAST_CHARACTER`, keyed by name so it survives the create/enum race).
/// A just-created character's name (decision 0423): its row gets selected (the ref's
/// `SELECT_LAST_CHARACTER`, keyed by name so it survives the create/enum race). Armed by
/// [`Roster::note_created`], which consumes it against the roster **already in hand** —
/// [`apply_roster_policy`] only has to answer the other arrival order.
just_created: Option<String>,
/// The auth realm-list entry this session connected to (the screen's realm banner, 0465);
/// refreshed with each roster.
@@ -132,9 +134,38 @@ pub(crate) struct Roster {
}
impl Roster {
/// Note a character the create screen just made, so the next roster update selects its row.
/// Note a character the create screen just made, and select its row.
///
/// **The consume happens here, not on the next roster message** (B119): the IO thread
/// re-enumerates and emits the fresh roster *before* the create result (`net::io`), so by the
/// time the result reaches [`crate::char_create`] the new row is normally already in
/// [`Self::chars`] and [`apply_roster_policy`] has already run for that list. Arming a flag for
/// "the next roster update" then waited for a message that never arrives again — the select
/// screen came back on the previously-selected row (reproduced: create a tauren on a
/// human-first account and the human is still standing there).
///
/// The arm survives when the name isn't in hand yet, so the reverse arrival order (result
/// first) is still answered by [`apply_roster_policy`].
pub(crate) fn note_created(&mut self, name: String) {
self.just_created = Some(name);
self.select_created_by_name();
}
/// Select the armed just-created row **by name**, disarming only on a hit. Case-insensitive:
/// vmangos normalizes a created name (`normalizePlayerName` — first letter upper, rest lower),
/// so a typed `ZZBULL` enumerates back as `Zzbull` and an exact compare would miss.
fn select_created_by_name(&mut self) {
let Some(name) = self.just_created.as_deref() else {
return;
};
if let Some(row) = self
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
{
self.selected = Some(row);
self.just_created = None;
}
}
/// The selected character, if any.
@@ -204,10 +235,19 @@ fn apply_roster_policy(
for msg in msgs.read() {
roster.chars = msg.characters.clone();
roster.realm = msg.realm.clone();
// Select the just-created character's row (the ref's SELECT_LAST_CHARACTER — ours keys by
// name so it survives the create → enum race), else clamp into range, first row default.
if let Some(name) = roster.just_created.take() {
roster.selected = roster.chars.iter().position(|c| c.name == name);
// A still-armed create (the result hasn't landed yet — the reverse of the order `net::io`
// actually sends in) selects its row here: by name first, else the LAST row, which is the
// ref's literal `SELECT_LAST_CHARACTER` → `SelectCharacter(numChars)` and lands on the same
// character regardless — vmangos enumerates `ORDER BY create_time, guid`, so the new one is
// last. Otherwise clamp into range, first row default.
if roster.just_created.is_some() {
roster.select_created_by_name();
if let Some(name) = roster.just_created.take() {
warn!(
"char select: created {name:?} is not on the fresh roster — selecting the last row"
);
roster.selected = roster.chars.len().checked_sub(1);
}
}
if roster.chars.is_empty() {
roster.selected = None;
@@ -423,10 +463,20 @@ fn debug_select_dialog(
*done = true;
}
/// Default delay before the select-screen shot fires (seconds from the screen coming up) — long
/// enough for the art, the glue scene and the geared model to settle.
const SELECT_SHOT_AT: f32 = 8.0;
/// The select-screen shot instrument (`WOW_CHARSELECT_SHOT_OUT=<path>`, decision 0465): once the
/// screen has been up a few seconds (art + scene + model settled), write one PNG of the window via
/// Bevy's own framebuffer readback — machine-checkable geometry without macOS screen-recording
/// permission. Inert without the env.
///
/// `WOW_CHARSELECT_SHOT_AT=<secs>` moves the shot (default [`SELECT_SHOT_AT`], measured from the
/// first frame the screen is up). The knob exists because the fixed 8 s lands *inside* the
/// create round-trip when the run is `WOW_CHARCREATE_SHOT` + `WOW_CHARCREATE_NAME`: the B119 retest
/// wants the screen a few seconds AFTER the new character lands, and it captured the frame 19 ms
/// after the create result — before the booth had re-baked the new selection.
fn debug_select_shot(
mut commands: Commands,
time: Res<Time>,
@@ -440,8 +490,12 @@ fn debug_select_shot(
*done = true;
return;
};
let at = std::env::var("WOW_CHARSELECT_SHOT_AT")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(SELECT_SHOT_AT);
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < 8.0 {
if time.elapsed_secs() - start < at {
return;
}
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
@@ -487,3 +541,88 @@ pub(crate) fn class_name(class: u8) -> &'static str {
_ => "?",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn character(guid: u64, name: &str) -> Character {
Character {
guid,
name: name.to_string(),
race: 1,
class: 1,
gender: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
level: 1,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
}
}
/// B119 — a created character is selected against the roster **already in hand**. `net::io`
/// re-enumerates and emits the fresh roster BEFORE the create result, so by the time
/// `note_created` runs the row exists and the roster message is spent; a version that only armed
/// a flag for "the next roster update" left the previously-selected row standing (reproduced
/// live: create a tauren on a human-first account, the human stays on the stage).
#[test]
fn a_created_character_is_selected_from_the_roster_in_hand() {
let mut roster = Roster {
chars: vec![
character(1, "Kerwind"),
character(2, "Xero"),
character(3, "Zzbullone"), // the fresh row, appended by the re-enum
],
selected: Some(0),
..default()
};
roster.note_created("Zzbullone".to_string());
assert_eq!(roster.selected, Some(2), "the new row must be selected");
assert!(roster.just_created.is_none(), "and the arm consumed");
}
/// The name key is case-insensitive: vmangos normalizes a created name
/// (`normalizePlayerName` — first letter upper, rest lower), so the string the create screen
/// typed is NOT what the enum sends back.
#[test]
fn the_created_name_matches_the_servers_normalized_spelling() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind"), character(2, "Zzbullone")],
selected: Some(0),
..default()
};
roster.note_created("ZZBULLONE".to_string()); // as typed
assert_eq!(roster.selected, Some(1));
}
/// The reverse arrival order (result first, roster after) stays armed and is answered by the
/// roster policy — and a name that still can't be found falls back to the LAST row, which is the
/// ref's literal `SELECT_LAST_CHARACTER` → `SelectCharacter(numChars)` and the same character
/// anyway (vmangos enumerates `ORDER BY create_time, guid`).
#[test]
fn an_unanswerable_create_stays_armed() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind")],
selected: Some(0),
..default()
};
roster.note_created("Zzbulltwo".to_string());
assert_eq!(
roster.just_created.as_deref(),
Some("Zzbulltwo"),
"no row to select yet — the arm must survive for the roster policy"
);
assert_eq!(roster.selected, Some(0), "and nothing is mis-selected");
}
}
+6
View File
@@ -428,6 +428,12 @@ pub(crate) struct SpellGoTargets {
/// *flight* visual (the projectile glancing off) stays a named approximation — ours ends
/// the flight at the target.
pub(crate) misses: Vec<(Entity, u8)>,
/// The ground point a dest-targeted cast launched at (`TARGET_FLAG_DEST_LOCATION`), in
/// **bevy** coords (converted at the apply seam like every scene position). A ground AOE's
/// GO arrives with empty hit/miss lists and only this. No consumer reads it yet — the
/// dest-anchored launch visual is pending the dispatched wow-re read (the persistent area
/// effect anchors to the DynamicObject create, not to this packet).
pub(crate) dest: Option<Vec3>,
/// The GO's ammo block (`castFlags & 0x20`, a ranged shot): the `ItemDisplayInfo` id of the
/// caster's ammo/thrown — the missile's model when the spell's visual chain has none (the
/// client's `0x479f40` fallback, decision 0099 phase 5).
@@ -440,8 +440,14 @@ pub(super) fn route_cast_visuals(
mut wounds: MessageWriter<WoundAnim>,
mut sounds: MessageWriter<SpellKitSound>,
mut fx: MessageWriter<SpellKitFx>,
mut missiles: MessageWriter<MissileSpawn>,
mut sheaths: MessageWriter<super::SheathRequest>,
// One tuple param (the 16-SystemParam ceiling): the missile spawns + the dest one-shot
// orders (0797) — the GO's two launch-side spawn lanes, resolved here where the catalogs
// live, spawned by `crate::entities`.
mut spawns: (
MessageWriter<MissileSpawn>,
MessageWriter<crate::entities::dest_fx::GroundBurst>,
),
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut weapon_src: WeaponVisualSrc,
@@ -699,6 +705,24 @@ pub(super) fn route_cast_visuals(
let Some(display) = spells.catalog.get(go.spell_id) else {
continue;
};
// The GO's dest one-shot (decision 0797, byte-pinned `0x6e8088`–`0x6e8143`): a
// dest-carrying GO plays the SpellVisual field-12 model ONCE at the packet's point —
// gate `field 6 == 0` (no missile owns the arrival) ∧ `field 12 ≠ 0`. NOT gated on the
// hit list (a pure ground cast's lists are empty and the burst still plays — the
// captured Flamestrike shape); fired here at the GO, never waiting on the dynobj
// create (wow-re trap #6: the burst precedes the object).
if let Some(dest) = go.dest {
if let Some(stages) = visuals.0.stages(display.visual) {
if stages.missile_gate == 0 && stages.area_effect != 0 {
if let Some(path) = visuals.0.effect_path(stages.area_effect) {
spawns.1.write(crate::entities::dest_fx::GroundBurst {
path: path.to_string(),
pos: dest,
});
}
}
}
}
// The caster's ranged fallback visual, resolved once per GO (the client's `0x6e802e`
// call into `0x60d450`) — feeds the inline impacts, the missile chain, and the flight.
let wv = weapon_src.caster(go.caster);
@@ -751,7 +775,7 @@ pub(super) fn route_cast_visuals(
let awaits_release =
resolve_kit(spells, &visuals.0, go.spell_id, |s| s.cast, || wv)
.is_some_and(|k| k.anim_id.is_some());
missiles.write(MissileSpawn {
spawns.0.write(MissileSpawn {
caster: go.caster,
spell_id: go.spell_id,
path,
@@ -42,6 +42,7 @@ fn app() -> App {
.add_message::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
@@ -200,6 +201,7 @@ fn precast_kit_sound_rings_once_at_start() {
.add_message::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([(
@@ -567,6 +569,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
.add_message::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
@@ -633,6 +636,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
spell_id,
hits: vec![target],
misses: Vec::new(),
dest: None,
ammo_display_id: None,
seq: 1,
});
+18 -1
View File
@@ -93,6 +93,14 @@ mod wmo_props;
use wmo_props::{resolve_wmo_gameobject_props, spawn_wmo_gameobject_props};
mod spell_fx;
use spell_fx::{attach_spell_fx, resolve_spell_fx};
/// Dest-anchored spell effects (decision 0797): a DynamicObject's persistent area visuals
/// (Blizzard's storm, Flamestrike's burn) + the GO dest one-shot burst. (Distinct from
/// `crate::ground_fx`, the flat-quad decal renderer this lane's models feed into.)
pub(crate) mod dest_fx;
use dest_fx::{
arm_ground_effects, attach_ground_fx_models, spawn_ground_bursts, tick_shard_emitters,
};
// The container feed reads the icon column off the same catalog resource (one DBC parse).
pub(crate) use attach::spawn_joints;
pub(crate) use equipment::ItemDisplays;
@@ -365,6 +373,8 @@ impl Plugin for EntitiesPlugin {
.init_resource::<missile::PendingMissiles>()
// The projectile flight-loop edges (`crate::sound::missile` consumes them).
.add_message::<MissileSound>()
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
.add_message::<dest_fx::GroundBurst>()
.add_systems(Startup, setup_entities.after(AssetSet::Open))
// The map-scope teardown (`world_map::MapChange`): drop every display/material dedup
// so a map's assets actually die with it — the #bugs teleport leak.
@@ -391,6 +401,11 @@ impl Plugin for EntitiesPlugin {
resolve_spell_fx,
move_missiles,
spawn_missiles,
// The dest-anchored lane (0797): the dynobj arm + the GO burst + the shard
// tick all create cache entries too — before the same-frame build below.
// One nested (unordered) element: three independent producers, and the
// outer tuple is at `chain()`'s 20-element ceiling.
(arm_ground_effects, spawn_ground_bursts, tick_shard_emitters),
// The fxview capture fixture (inert outside `WOW_CAPTURE=fxview`): creates
// its cache entry before the same-frame build, attaches after it.
spell_fx::drive_fx_view,
@@ -407,7 +422,9 @@ impl Plugin for EntitiesPlugin {
spawn_wmo_gameobject_props,
attach_held_items,
attach_spell_fx,
attach_missile_models,
// One nested (unordered) element — two independent free-model attach
// passes; the outer tuple is at `chain()`'s 20-element ceiling.
(attach_missile_models, attach_ground_fx_models),
// Tick the live per-instance tint clones AFTER the attach passes registered
// them, so a clone's first drawn frame is already on its own clock.
spell_fx::tick_fx_tint,
+3 -1
View File
@@ -1080,7 +1080,9 @@ pub(super) fn attach_entity_visuals(
);
None
}
EntityKind::Other => None,
// A DynamicObject is deliberately invisible as an *object* — its look is the
// spell's area effect (the dest-anchored visual lane), never a fallback cube.
EntityKind::DynamicObject | EntityKind::Other => None,
};
if let Some((material, mesh, lift)) = fallback {
// Tag the cube as a pickable unit too (kind `Creature` covers units + player bodies), so
+361
View File
@@ -0,0 +1,361 @@
//! **Dest-anchored spell effects** (decision 0797, B132's second half): what a ground cast shows
//! at the point. Two producers, one spawn/attach lane:
//!
//! - **The DynamicObject machine** ([`arm_ground_effects`]): a TYPEID-6 create is the anchor a
//! persistent area effect hangs on (Blizzard's storm, Flamestrike's burn). The reference
//! builds **two disjoint visuals** (wow-re `dynobject-visual-machine.md` — never through the
//! unit-kit pipeline; all 15 `PlaySpellVisualKit` call sites censused, none on this class):
//! - **Visual A** — the object's own `.mdx`: SPELLID → `Spell.dbc` SpellVisual →
//! `SpellVisual` field 11 ≠ 0 gate → field 12 → `SpellVisualEffectName` field 2 path
//! (`0x5d57c0`), instanced at the object's position **verbatim** (no terrain projection),
//! Z-rotated by `DYNAMICOBJECT_FACING` (`0x613ef0` → `0x7bdd60`), looping its sequence
//! (`0x5d5580` re-fires on completion).
//! - **Visual B** — the shard emitter (`AUBlizzardObject`, `0x5d55c0` → `0x6ece30`):
//! `SpellVisual` field 13 → `SpellVisualKit` → the first `CharProcType == 9` block; its
//! `CharParamZero` small-int-decodes (`bits(p0 + 512.0) >> 14 & 0xff`) into a **hardcoded
//! 7-entry model table** (`0x870e24` — [`SHARD_MODELS`]), `CharParamOne` is the emit rate
//! (× the graphics-quality factor — we run max quality, ×1.0). Each emitted shard spawns at
//! a random offset inside `DYNAMICOBJECT_RADIUS` — the wire radius IS the visual footprint
//! (`0x6ebad0`). The kit's field-13 `SoundEntries` is the looping area sound.
//! - **The GO dest one-shot** ([`spawn_ground_bursts`] ← the router's [`GroundBurst`]):
//! `SMSG_SPELL_GO` itself plays the field-12 model ONCE at the packet's dest point when
//! `SpellVisual` field 6 == 0 (no missile owns the arrival) — `0x6e8088`–`0x6e8143`, a
//! self-terminating CEffect. Flamestrike's initial burst; it fires **before** the dynobj
//! create arrives and must not wait for it (wow-re trap #6).
//!
//! **Teardown is a tail, not a snap** (trap #3): `SMSG_DESTROY_OBJECT` despawns the anchor —
//! visual A and the emitter die with it (the ref zeroes the emit rate at `0x6ecf20`) — but the
//! already-emitted shards are FREE entities that run out their own one-pass lifetimes, exactly
//! the ref's "spawned particles finish". The looping sound dies via the sound module's
//! `RemovedComponents<NetEntity>` hold-loop reaper.
//!
//! Named approximations: the ref's 3 s sound fade at destroy is a hard stop here (no channel
//! fade affordance yet); the hardcoded loop sequence id `0x9e` (fired when `obj+0x190` bit 1 is
//! set — the bit's source is unpinned) is approximated by looping the model's first clip; the
//! quality-tier emit factor is pinned at ×1.0 (max); Flamestrike's kit sound plays even though
//! its kit has no type-9 proc (the ref couples sound to the emitter object — whether a
//! procless kit still sounds is unread; the burn crackle missing would be the wronger look).
use bevy::prelude::*;
use crate::creature_anim::{SpellKitSound, SpellVisuals};
use crate::model_render::m2_url;
use crate::net::{NetEntity, ObjectStore};
use benilla_protocol::EntityKind;
use super::spell_fx::{attach_effect_visuals, SpellFx};
use super::{DisplayModel, ModelHandle};
/// The client's hardcoded shard-model table (`0x870e24`, 7 entries — wow-re
/// `dynobject-visual-machine.md` Q-A1). `CharParamZero`'s decoded small int indexes it.
const SHARD_MODELS: [&str; 7] = [
"Spells\\Blizzard_Impact_Base.mdx",
"Spells\\RainOfFire_Impact_Base.mdx",
"Spells\\CallLightning_Impact.mdx",
"Spells\\FlamestrikeSmall_Impact_Base.mdx",
"Spells\\DeathAndDecay_Area_Base.mdx",
"Spells\\ArcaneShot_Area.mdx",
"Spells\\StarShards_Impact_Base.mdx",
];
/// The `CharProcType` the dynobj emitter chain scans for (`0x5d55c0`).
const PROC_TYPE_SHARD_EMITTER: u32 = 9;
/// The exact small-int decode the client applies to `CharParamZero` (`bits(p0 + 512.0) >> 14 &
/// 0xff`) — clamped into [`SHARD_MODELS`] because the client itself has **no bounds check**
/// (`mov cl,al` — data ≥ 7 reads past the table; wow-re trap #2).
fn shard_model_index(param0: f32) -> usize {
let idx = ((param0 + 512.0).to_bits() >> 14 & 0xff) as usize;
idx.min(SHARD_MODELS.len() - 1)
}
/// A free-standing dest-anchored effect-model instance (visual A, a shard, a GO burst) —
/// [`attach_ground_fx_models`] hangs the model parts once the M2 builds.
#[derive(Component)]
pub(super) struct GroundFx {
/// The [`SpellFx`] model-cache key.
path: String,
/// `true` = loop the clip for the instance's whole life (visual A — the ref re-fires its
/// sequence on completion); `false` = one pass of sequence 0 then despawn (a shard, a
/// burst — the kit pipeline's self-termination clock).
looping: bool,
/// Parts attached (the model was ready).
spawned: bool,
/// The repeat override landed on the live `AnimationPlayer` (loop instances only).
loop_armed: bool,
/// One-shot self-termination deadline (`time.elapsed_secs()`), set at attach.
expires: Option<f32>,
}
impl GroundFx {
fn new(path: String, looping: bool) -> Self {
Self {
path,
looping,
spawned: false,
loop_armed: false,
expires: None,
}
}
}
/// The shard emitter riding a DynamicObject anchor (visual B — the ref's `AUBlizzardObject`).
/// Dies with the anchor (emission stops at destroy); its spawned shards are free entities and
/// finish on their own — the ref's emitter tail.
#[derive(Component)]
pub(super) struct ShardEmitter {
/// The [`SHARD_MODELS`] path.
path: String,
/// `DYNAMICOBJECT_RADIUS` — the per-shard random spawn spread (the AoE footprint).
radius: f32,
/// Shards per second (`CharParamOne` × the quality factor, ×1.0 here).
rate: f32,
/// Fractional emissions carried between frames.
accum: f32,
/// A tiny LCG for the spawn offsets (the ref rolls real randoms; seeded per emitter).
rng: u64,
}
/// The router's dest one-shot order (`SMSG_SPELL_GO` + `TARGET_FLAG_DEST_LOCATION`, gate
/// `SpellVisual` field 6 == 0 ∧ field 12 ≠ 0 — resolved router-side where the catalogs live).
#[derive(Message, Clone)]
pub(crate) struct GroundBurst {
/// The field-12 `SpellVisualEffectName` model path.
pub(crate) path: String,
/// The dest point, bevy coords (converted at the apply seam).
pub(crate) pos: Vec3,
}
/// Arm a freshly-created DynamicObject anchor: resolve the spell's visual row and hang the two
/// visuals + the area sound (module doc). Runs on `Added<ObjectStore>` so the create's fields
/// are the trigger; a dynobj is created once and never re-created in place (a re-cast is a new
/// guid — captured live, 0797).
pub(super) fn arm_ground_effects(
mut commands: Commands,
created: Query<(Entity, &NetEntity, &ObjectStore), Added<ObjectStore>>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
mut sounds: MessageWriter<SpellKitSound>,
) {
let (Some(visuals), Some(spells), Some(mut fx)) = (visuals, spells, fx) else {
return;
};
for (anchor, net, store) in &created {
if net.kind != EntityKind::DynamicObject {
continue;
}
let Some(spell_id) = store.0.dynamicobject_spell_id() else {
continue;
};
let Some(stages) = spells
.catalog
.get(spell_id)
.and_then(|d| visuals.0.stages(d.visual))
else {
debug!("dest_fx: dynobj spell {spell_id} has no visual row — invisible area");
continue;
};
let facing = store
.0
.dynamicobject_position()
.map(|(_, f)| f)
.unwrap_or(0.0);
// Visual A — the object's own model, gated on field 11, Z-rotated by FACING. A child of
// the anchor: the destroy pop takes it exactly like the ref's scene-node teardown.
if stages.area_gate != 0 && stages.area_effect != 0 {
if let Some(path) = visuals.0.effect_path(stages.area_effect) {
let path = path.to_string();
ensure_model(&mut fx, &asset_server, &path);
let child = commands
.spawn((
GroundFx::new(path, true),
Transform::from_rotation(Quat::from_rotation_y(facing)),
Visibility::default(),
))
.id();
commands.entity(anchor).add_child(child);
}
}
// Visual B — the shard emitter, from the area kit's first type-9 CharProc block; the
// kit's own sound column is the looping area sound (a tracked hold loop the sound
// module reaps when the anchor's NetEntity is removed).
if let Some(kit) = visuals.0.kit(stages.area_kit) {
if let Some(proc) = kit
.char_procs
.iter()
.find(|p| p.proc_type == PROC_TYPE_SHARD_EMITTER)
{
let path = SHARD_MODELS[shard_model_index(proc.param0)].to_string();
ensure_model(&mut fx, &asset_server, &path);
commands.entity(anchor).insert(ShardEmitter {
path,
radius: store.0.dynamicobject_radius().unwrap_or(0.0),
rate: proc.param1,
accum: 0.0,
rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(),
});
}
if let Some(kit_sound) = kit.sound {
sounds.write(SpellKitSound::Play {
entity: anchor,
kit_sound,
});
}
}
debug!(
"dest_fx: dynobj armed — spell {spell_id}, gateA={} effect={} kit={} radius {:?}",
stages.area_gate,
stages.area_effect,
stages.area_kit,
store.0.dynamicobject_radius(),
);
}
}
/// Spawn the router's GO dest one-shots — free entities at the packet's point, one sequence
/// pass, then gone. Fired at the GO, never waiting on the dynobj create (trap #6).
pub(super) fn spawn_ground_bursts(
mut commands: Commands,
mut bursts: MessageReader<GroundBurst>,
fx: Option<ResMut<SpellFx>>,
asset_server: Res<AssetServer>,
) {
let Some(mut fx) = fx else { return };
for burst in bursts.read() {
ensure_model(&mut fx, &asset_server, &burst.path);
commands.spawn((
GroundFx::new(burst.path.clone(), false),
Transform::from_translation(burst.pos),
Visibility::default(),
));
}
}
/// Emit shards: `rate` per second, each a free one-shot instance at a random offset inside the
/// wire radius (uniform over the disc, horizontal plane — the ref's per-particle spread scaled
/// by `+0x11c`).
pub(super) fn tick_shard_emitters(
mut commands: Commands,
time: Res<Time>,
mut emitters: Query<(&mut ShardEmitter, &GlobalTransform)>,
) {
for (mut em, tf) in &mut emitters {
em.accum += em.rate * time.delta_secs();
while em.accum >= 1.0 {
em.accum -= 1.0;
// xorshift* — cheap, per-emitter deterministic, no rand dependency.
let mut next = || {
em.rng ^= em.rng >> 12;
em.rng ^= em.rng << 25;
em.rng ^= em.rng >> 27;
em.rng.wrapping_mul(0x2545F4914F6CDD1D)
};
let u1 = (next() >> 40) as f32 / (1u64 << 24) as f32;
let u2 = (next() >> 40) as f32 / (1u64 << 24) as f32;
let r = em.radius * u1.sqrt();
let theta = u2 * std::f32::consts::TAU;
let offset = Vec3::new(r * theta.cos(), 0.0, r * theta.sin());
commands.spawn((
GroundFx::new(em.path.clone(), false),
Transform::from_translation(tf.translation() + offset),
Visibility::default(),
));
}
}
}
/// Attach model parts to pending instances whose M2 finished building (the missile pattern —
/// free world models, ground-anchored so authored flat quads decal to the terrain), start the
/// one-shot clocks, and run both reapers (one-shot expiry; the loop-repeat override).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn attach_ground_fx_models(
mut commands: Commands,
time: Res<Time>,
mut instances: Query<(Entity, &mut GroundFx, Option<&mut AnimationPlayer>)>,
fx: Option<Res<SpellFx>>,
mut wow_materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
) {
let Some(fx) = fx else { return };
let now = time.elapsed_secs();
for (entity, mut inst, player) in &mut instances {
if !inst.spawned {
let Some(dm) = fx.models.get(&inst.path) else {
continue;
};
if !attach_effect_visuals(
&mut commands,
entity,
dm,
now,
true, // a dest-anchored area model's flat quads ARE ground decals
None, // a free world model — any trail stays world-frozen
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
None,
) {
continue; // model still building — attach on a later pass
}
inst.spawned = true;
if !inst.looping {
// One pass of the first sequence — the kit pipeline's completion-callback
// stand-in (`spell_fx`'s span clock, same law).
let span = dm.first_seq_span.unwrap_or(super::spell_fx::FALLBACK_SPAN);
inst.expires = Some(now + span);
}
continue; // the AnimationPlayer lands next frame — the loop override waits
}
// The loop override: the ref re-fires the sequence on completion (`0x5d5580`); ours
// marks the live player's animations repeat-forever, once.
if inst.looping && !inst.loop_armed {
if let Some(mut player) = player {
for (_, anim) in player.playing_animations_mut() {
anim.repeat();
}
inst.loop_armed = true;
}
}
if let Some(expires) = inst.expires {
if now >= expires {
commands.entity(entity).despawn();
}
}
}
}
/// Create the shared model-cache entry (the missile/kit pattern) so the M2 load starts the
/// frame the effect is armed.
fn ensure_model(fx: &mut SpellFx, asset_server: &AssetServer, path: &str) {
fx.models
.entry(path.to_string())
.or_insert_with(|| DisplayModel {
handle: ModelHandle::M2(asset_server.load(m2_url(path))),
..super::empty_shell()
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The `0x5d55c0` decode: `bits(f32(param0 + 512.0)) >> 14 & 0xff` recovers the small int
/// exactly (the real rows carry 0.0 → Blizzard, 1.0 → Rain of Fire), and out-of-table data
/// clamps instead of reading past the 7 entries (the client's own missing bounds check —
/// wow-re trap #2).
#[test]
fn shard_model_index_decodes_and_clamps() {
assert_eq!(shard_model_index(0.0), 0);
assert_eq!(shard_model_index(1.0), 1);
assert_eq!(shard_model_index(6.0), 6);
assert_eq!(shard_model_index(7.0), 6, "clamped, not read past");
assert_eq!(shard_model_index(200.0), 6);
assert_eq!(SHARD_MODELS[0], "Spells\\Blizzard_Impact_Base.mdx");
assert_eq!(SHARD_MODELS[1], "Spells\\RainOfFire_Impact_Base.mdx");
}
}
+4 -2
View File
@@ -63,8 +63,10 @@ use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle};
const ATTACH_FALLBACKS: [u16; 2] = [0xf, 0x13];
/// The self-termination span when an effect model has no sequence table at all (the cube
/// fallback and kin) — long enough to read as a flash, short enough never to linger.
const FALLBACK_SPAN: f32 = 1.0;
/// fallback and kin) — long enough to read as a flash, short enough never to linger. Shared
/// with the dest-anchored lane's one-shots ([`super::dest_fx`]) — same completion-callback
/// stand-in.
pub(super) const FALLBACK_SPAN: f32 = 1.0;
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
/// like the wire's, in a serverless capture where nothing else claims one.
+11
View File
@@ -5,6 +5,17 @@
//! a per-frame MCSH sample at the object's node position and a linear intensity ramp (`0x69e770`, the
//! step constant `[0x810808] = 3.3333`/s), not a static spawn-time bake.
//!
//! **OPEN, and it is this file's whole premise (decision 0796 §4).** §9's chain is byte-verified up to
//! its last link — "the unit's own light node fills the unit's committed light" — and that link is
//! INFERRED, not verified. Observation contradicts it: a player + three NPCs across two of the
//! reference's own apitraces commit their sun at gain **exactly 1.0**, never 2.5 or 0.5, while the same
//! frames' ADT doodads span the full 0.5/0.85/1.0/1.874/2.5 ramp (wow-re
//! `trace-forensics-northshire-d3d` §5) — the signature of `0x672a20`'s null-node fallback, i.e. units
//! may not be on the terrain-shade chain at all. If that lands, this system keeps its GameObject and
//! ambient-word duties and loses its unit/player intensity ramp. Not pre-emptively torn out: the
//! mechanism is an open wow-re follow-up, and the shader's `min(I,1)` already confines the visible
//! error to a unit on MCSH-shadowed ground.
//!
//! Structure mirrors the reference's one-light-node-per-object: [`GroundShade`] lives on the **net
//! entity root** (the `[obj+0xe0]` twin), sampled at the root's feet and ramped there; the resulting
//! shade byte is written to every M2 part in the root's descendant tree — body submeshes, and held
+40 -3
View File
@@ -60,10 +60,17 @@ pub(crate) fn seat_outline_copies(
/// The ONE copy of the formula — every glue spawn site and every rescale check reads it here, and
/// each screen's lifecycle system compares its tree's baked scale against this to know when a
/// window resize (mac fullscreen, a drag) has invalidated the tree.
///
/// **There is no lower clamp** (B120). A floor of 1.0 draws the 768-unit-tall authored layout into a
/// shorter window and the overflow simply falls off the bottom — silently, and always the
/// *bottom-most* controls: on the create screen that is the last customization row and the
/// **RANDOMIZE** button (reproduced at `WOW_WIN=1276x677` — both gone, along with the foot of the
/// right-hand race/class panels). The reference has no such floor: its glue screens are authored in
/// the 768-tall virtual space and scaled by the window height, so a short window makes everything
/// smaller and nothing missing. The upper clamp stays — that is the shipped size on a tall screen,
/// and lifting it would resize the director's UI without being asked.
pub(crate) fn screen_scale(window: Option<&Window>) -> f32 {
window
.map(|w| (w.height() / 768.0).clamp(1.0, 2.2))
.unwrap_or(1.0)
window.map(|w| (w.height() / 768.0).min(2.2)).unwrap_or(1.0)
}
/// Mirror every outlined text's content into its black copies ([`widgets::outlined_text`]) — the
@@ -173,3 +180,33 @@ pub(crate) fn glue_button_visuals(
}
}
}
#[cfg(test)]
mod tests {
use super::screen_scale;
use bevy::prelude::*;
use bevy::window::WindowResolution;
/// The authored layout must FIT the window at every height — the B120 regression.
/// [`screen_scale`] is the only thing standing between a 768-unit-tall tree and a shorter
/// window, so a floor there silently amputates the bottom of every glue screen (the create
/// screen's RANDOMIZE button is authored at y 719..749 of 768).
#[test]
fn the_authored_layout_fits_any_window_height() {
/// Bottom edge of the create screen's lowest control (the RANDOMIZE button) in authored
/// units: the configuration tower's TOPLEFT y 74 + its 645 in-tower offset + 30 tall.
const LOWEST_CONTROL: f32 = 74.0 + 645.0 + 30.0;
for h in [480u32, 600, 677, 720, 768, 900, 1286, 2160] {
let window = Window {
resolution: WindowResolution::new(1600, h),
..default()
};
let s = screen_scale(Some(&window));
assert!(
LOWEST_CONTROL * s <= window.height(),
"at a {h}px window (scale {s}) the lowest glue control lands at {} — off the bottom",
LOWEST_CONTROL * s
);
}
}
}
+11
View File
@@ -64,6 +64,16 @@ pub struct WorldClick;
#[derive(Message, Clone, Copy)]
pub struct WorldRightClick;
/// The right button's **DOWN edge** in the world — emitted at the press, before the click-vs-drag
/// test even starts, whenever the press belongs to the world (in the viewport off the UI, or any
/// press while a look session already owns the hidden cursor). The reference's
/// `CGWorldFrame::OnMouseDown 0x483c40` analogue: ground-targeting's right-click cancel hangs off
/// this edge (`0x492c20`, wow-re `world-click-targeting.md` Q3), which fires whether the press
/// becomes a click OR a turn-drag, and consumes nothing (the ref handler returns 0, so the
/// BUTTON2 turn and the release's context click still run).
#[derive(Message, Clone, Copy)]
pub struct WorldRightPress;
/// Whether mouseover picking runs. Today it's armed/disarmed by the **Ctrl+Cmd+I** inspector toggle
/// ([`toggle_inspect`]).
#[derive(Resource, Default)]
@@ -515,6 +525,7 @@ impl Plugin for InteractPlugin {
.init_resource::<journal::CastJournal>()
.add_message::<WorldClick>()
.add_message::<WorldRightClick>()
.add_message::<WorldRightPress>()
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
// frame's. (`toggle_inspect` itself no longer needs the ordering: its Ctrl+Cmd+I chord
+124 -7
View File
@@ -75,12 +75,20 @@ pub(crate) const LIGHT_HEADER_ROWS: usize = 21;
/// interior lane runs, per the disassembly of the shipped ARB program (wow-re
/// `system/models/scratch/model2-bls-vertex-sh.md`): `E(n) = D·(3 + 16μ + 15μ²)/34`, μ = n·u
/// toward-light, EVERY band linear in the committed colour `D` — there is no separate amplitude
/// scalar, and the per-instance intensity lives entirely in that colour (the shader multiplies
/// ALL sun terms by I, packed here at I = 1). The peak (μ=1) equals the FFP walls' `D·(N·L)`
/// scalar, and the per-instance intensity lives entirely in that colour (a consumer multiplies
/// ALL sun terms by I; packed here at I = 1). The peak (μ=1) equals the FFP walls' `D·(N·L)`
/// peak by construction (the 16/17 accumulate scale exists for exactly that), and the closed form
/// never goes meaningfully negative — the old trace-fit's ~¼-strength lobe with a negative back
/// side (shadow-side characters turned blue as the warm channels floored at 0) is superseded.
///
/// **The SH block (rows 6-11, row 12 `.xyz`, row 17 `.yzw`) is read only under the response A/B**
/// ([`m2_sh_response`], `WOW_M2_SH=1` → row 12 `.w`). 0410 took the exterior M2 lane off this curve
/// onto the hard-cutoff FFP matte on the director's look call and nothing consumed the rows for
/// months; 0796 refuted the fidelity premise behind that retirement (the reference's M2 lane IS
/// this SH shader), which reopens the call and puts the eval back beside the cutoff so the two can
/// be seen. The interior-prop and glue-rig lanes are unaffected — they fold their own probes
/// through the per-instance `prop_probes` table, not these rows.
///
/// This is the ONE packer for the scene light ([`build_light_data`]) AND the portrait booth's
/// studio light (`portrait::setup_booths`): the booth used to hand-copy the layout and rendered
/// black portraits the day 0354 moved the lit lanes onto rows it never wrote. A producer that
@@ -109,8 +117,8 @@ pub(crate) fn pack_model_core_rows(
rows[12][1] = sun[6].y;
rows[12][2] = sun[6].z;
// 17 `.yzw` — the sun's SH DC redistribution at intensity 1 (`D·(4/17)(0.375+0.9375(uₓ²+u_y²))`
// per channel): the exterior M2 lane adds it × the per-instance intensity. `.x` (SIDN) is the
// scene's.
// per channel): an SH consumer adds it × the per-instance intensity (dormant since 0410 — see
// the doc above). `.x` (SIDN) is the scene's.
rows[17][1] = sun[0].w;
rows[17][2] = sun[1].w;
rows[17][3] = sun[2].w;
@@ -134,6 +142,19 @@ pub(crate) fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
rgb.map(|c| c.max(0.0))
}
/// `WOW_M2_SH=1` ⇒ the exterior M2 doodad/entity lane evaluates the reference's own order-2
/// `Model2.bls` response instead of the shipped hard-cutoff FFP matte. The **one open director call**
/// on this lane (0796 §5): the reference genuinely runs the SH lobe, and 0410 replaced it with the
/// cutoff on a look call made when both lanes were believed reference-real — only one is.
///
/// Read once (the process can't change its mind mid-run) and published to the shaders in row 12 `.w`.
/// Deliberately NOT a resource or a debug-menu toggle: it is a short-lived instrument for one A/B,
/// and the cheapest thing to delete when the call lands.
fn m2_sh_response() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| matches!(std::env::var("WOW_M2_SH").as_deref(), Ok("1")))
}
/// Capacity of the packed point-light table (fixed-size in the WGSL mirror structs — keep in sync).
/// 256 lights × 2 rows × 16 B = 8 KB — generous for the densest streamed village/city interior set;
/// [`build_light_data`] packs the nearest-to-camera first when over capacity.
@@ -265,9 +286,9 @@ fn build_light_data(
// multiplies each WMO SIDN material's authored emissive colour by it on the lit lanes).
// `.yzw` is the core packer's below.
rows[17][0] = l.sidn_night;
// 18/19 — the INTERIOR fog triple (see the layout doc above). 19.zw (and 12.w) are free
// lanes: they carried retired dials (the 0273/0354-era A/Bs, the point gain, the 0750/0751
// sun calibration) — the shaders read the faithful path unconditionally now (0753).
// 18/19 — the INTERIOR fog triple (see the layout doc above). 19.zw are free lanes: they
// carried retired dials (the 0273/0354-era A/Bs, the point gain, the 0750/0751 sun
// calibration). 12.w was free too until 0796 gave it the response A/B (below).
rows[18] = [
l.wmo_fog_color[0],
l.wmo_fog_color[1],
@@ -278,6 +299,14 @@ fn build_light_data(
// Rows 0-2, the SH block 6-12.xyz, and the sun DC (17.yzw) — the shared model-light core
// (also the portrait booth's packer). Row 20 (point_count) is the point-table pack's below.
pack_model_core_rows(rows, l.ambient, l.diffuse, l.sun_dir);
// 12 `.w` — the exterior M2 RESPONSE A/B (0796 §5), the one open director call on this lane.
// 0 (default) = the shipped hard-cutoff FFP matte, 0410's look call. 1 = the reference's own
// response, the order-2 `Model2.bls` lobe over the rows packed just above. Not a calibration
// dial (0750/0751's kind, rightly retired) — it is a two-way switch between two fully specified
// laws, and it exists because §5 is a look question that only the director can answer and they
// cannot answer it without seeing both. It goes the moment they call it: whichever side loses,
// this lane and the loser's code come out together.
rows[12][3] = if m2_sh_response() { 1.0 } else { 0.0 };
// The dynamic point-light table (decision 0278): every spawned point light within
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
@@ -412,6 +441,94 @@ fn upload_light(
mod tests {
use super::*;
/// GOLDEN — the exterior M2 **response A/B** (0796 §5): the SH branch of `wow_model.wgsl` must
/// reproduce `E = A + I·D·(4/17)(0.375 + 2μ + 1.875μ²)` off the rows [`pack_model_core_rows`]
/// writes, with `A` NOT scaling by the per-instance intensity and every sun band scaling by it
/// exactly once (never I²).
///
/// This exists because the capture harness cannot check it. `visual.sh`-style captures are
/// bit-deterministic on static scenes (canal-noon: MAE 0.000) but NOT on the entity/GameObject
/// scenarios this lane owns — measured run-to-run at MAE 5.1 (chest-shade-rear) and 8.8
/// (creature-sun-rear), a noise floor far above the ~0.24 signal the A/B produces. So the lane's
/// correctness is pinned HERE, deterministically, and the captures are only good for "it
/// compiles and it moves pixels".
///
/// `eval_sh_lane` mirrors the WGSL lane-for-lane on purpose — read the two side by side; a
/// channel or row swap in the shader is caught by eye against this, not by this test.
#[test]
fn the_sh_response_lane_matches_the_closed_form_at_every_intensity() {
// Stormwind, minute ≈1185 — the bands wow-re independently recovered from the reference's
// own uploaded shader constants (0796 §1), so the test is anchored on a real committed pair.
let ambient = [102.0 / 255.0, 97.0 / 255.0, 123.0 / 255.0];
let diffuse = [255.0 / 255.0, 112.0 / 255.0, 0.0];
let sun_dir = Vec3::new(0.31, -0.82, 0.48).normalize(); // travel dir; to-light = −this
let mut rows = [[0.0f32; 4]; LIGHT_HEADER_ROWS];
pack_model_core_rows(&mut rows, ambient, diffuse, sun_dir);
/// The SH branch of `wow_model.wgsl`'s exterior doodad/entity lane, verbatim.
fn eval_sh_lane(rows: &[[f32; 4]; LIGHT_HEADER_ROWS], n: Vec3, intensity: f32) -> [f32; 3] {
let quad = [n.x * n.y, n.y * n.z, n.z * n.z, n.x * n.z];
let x2y2 = n.x * n.x - n.y * n.y;
let dot3 = |r: [f32; 4]| r[0] * n.x + r[1] * n.y + r[2] * n.z;
let dot4 =
|r: [f32; 4]| r[0] * quad[0] + r[1] * quad[1] + r[2] * quad[2] + r[3] * quad[3];
[0usize, 1, 2].map(|ch| {
// sh_c10_{r,g,b} = rows[6+ch] (.w = ambient) · sh_c13_{r,g,b} = rows[9+ch]
// sh_c16.xyz = rows[12][ch] · grade.yzw = rows[17][1+ch] (the sun's DC, at I=1)
rows[6 + ch][3]
+ rows[17][1 + ch] * intensity
+ intensity * (dot3(rows[6 + ch]) + dot4(rows[9 + ch]) + rows[12][ch] * x2y2)
})
}
let u = -sun_dir; // toward-light unit
let f = |mu: f32| (4.0 / 17.0) * (0.375 + 2.0 * mu + 1.875 * mu * mu);
// A side-on normal (μ = 0) and a mid-back one (μ ≈ −0.53, the lobe's negative dip).
let side = u.cross(Vec3::Y).normalize();
let mid_back = (u * -0.5333 + side * (1.0f32 - 0.5333 * 0.5333).sqrt()).normalize();
for (label, n) in [
("facing", u),
("away", -u),
("side-on", side),
("mid-back", mid_back),
] {
for intensity in [0.5f32, 1.0, 2.5] {
let mu = n.dot(u);
let got = eval_sh_lane(&rows, n, intensity);
for ch in 0..3 {
let want = ambient[ch] + intensity * diffuse[ch] * f(mu);
assert!(
(got[ch] - want).abs() < 1e-5,
"{label} I={intensity} ch{ch}: got {} want {want}",
got[ch]
);
}
}
}
// The peak is calibrated to the FFP peak by construction (the 16/17 accumulate scale) — so
// the A/B changes NOTHING on a surface square to the sun, and the whole visible difference
// lives on the shadow side. This is why the switch reads subtle rather than dramatic.
let peak = eval_sh_lane(&rows, u, 1.0);
for ch in 0..3 {
let ffp_peak = ambient[ch] + diffuse[ch]; // ambient + D·max(N·L,0) at N·L = 1
assert!(
(peak[ch] - ffp_peak).abs() < 1e-5,
"peak ch{ch}: SH {} vs FFP {}",
peak[ch],
ffp_peak
);
}
// And the mid-back dip really is BELOW ambient — the low-order-SH ringing the reference
// authors. Clamping the sun term per-term instead of the sum would erase it.
let dip = eval_sh_lane(&rows, mid_back, 1.0);
assert!(
dip[0] < ambient[0],
"mid-back should dip below ambient: {} vs {}",
dip[0],
ambient[0]
);
}
/// GOLDEN — the **commit clamp** (wow-re `m2-light-emitter-instances.md` §6a: `0x71ca80` with
/// `w = 1.0` degenerates to clamp01), driven end to end through the real packer so removing the
/// clamp from the pack expression fails here rather than in the director's eye.
+6
View File
@@ -75,6 +75,12 @@ pub(crate) struct MatKey {
pub(crate) enum ShadeSel {
/// Lit ground, the boosted intensity family (the binary's 2.5): ADT map doodads on unshadowed
/// ground, and every entity M2 (§9: units carry the same 2.5/0.5 chain as doodads).
///
/// The entity half is **contradicted by observation** — a player + three NPCs across two
/// reference apitraces all commit at gain exactly 1.0 while the same frames' doodads span
/// 0.5..2.5 (decision 0796 §4). Left as-is deliberately: the shader's `min(I,1)` already lands
/// a lit-ground unit on 1.0, so only a unit on MCSH-shadowed ground renders wrong, and §9's
/// contradicted link is the INFERRED one wow-re still has open.
Lit,
/// Lit ground, intensity 1.0: an exterior WMO MODD prop — §8b, byte-verified never to reach
/// the 2.5 site (a Stormwind street fountain is NOT brightened like an Elwynn tree).
+5
View File
@@ -648,6 +648,11 @@ pub(crate) enum ClientCommand {
/// Cast a spell (`CMSG_CAST_SPELL`): `target: None` = self/implicit-target cast, `Some(guid)`
/// = explicit unit target. Answered by `SMSG_CAST_RESULT` (a `CastResult` event).
CastSpell { spell_id: u32, target: Option<u64> },
/// Cast a spell at a **ground point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`,
/// decision 0792): the targeting-cursor commit for a ground-targeted AOE. `dest` is the
/// clicked world point in **WoW coords** (`bevy_to_wow` at the send site — the wire never
/// sees Bevy space). Answered by `SMSG_CAST_RESULT`.
CastSpellAtDest { spell_id: u32, dest: [f32; 3] },
/// Cancel one of our own auras (`CMSG_CANCEL_AURA`, decision 0257): the right-click-a-buff wire,
/// carrying the **spell id** (the server cancels by spell, not slot). No answer packet — the
/// removal comes back as a `UNIT_FIELD_AURA` delta. Sent by the aura feed's cancel drain.
+2
View File
@@ -923,6 +923,7 @@ pub(super) fn apply_net_updates(
misses,
target,
go_target,
dest,
ammo_display_id,
item_caster,
} => spell_go(
@@ -933,6 +934,7 @@ pub(super) fn apply_net_updates(
misses,
target,
go_target,
dest,
ammo_display_id,
item_caster,
&mut commands,
+10 -2
View File
@@ -272,6 +272,7 @@ pub(super) fn spell_go(
misses: Vec<(u64, u8)>,
target: Option<u64>,
go_target: Option<u64>,
dest: Option<[f32; 3]>,
ammo_display_id: Option<u32>,
item_caster: Option<u64>,
commands: &mut Commands,
@@ -297,7 +298,8 @@ pub(super) fn spell_go(
) {
debug!(
"net: spell go {spell_id} by {caster:#x} ({} hit(s), {} miss(es), flags \
{cast_flags:#x}, target {target:?}, go_target {go_target:?}, ammo {ammo_display_id:?})",
{cast_flags:#x}, target {target:?}, go_target {go_target:?}, dest {dest:?}, \
ammo {ammo_display_id:?})",
hits.len(),
misses.len()
);
@@ -442,12 +444,17 @@ pub(super) fn spell_go(
.iter()
.filter_map(|&(g, code)| index.0.get(&g).map(|&e| (e, code)))
.collect();
if !hits.is_empty() || !misses.is_empty() {
// A pure dest cast (ground AOE — empty hit/miss lists) rides the same message: the
// point is a target too (the B132 follow-up; the launch-side dest visual is the
// router's to resolve).
let dest = dest.map(benilla_assets::coords::wow_to_bevy);
if !hits.is_empty() || !misses.is_empty() || dest.is_some() {
go_targets.write(SpellGoTargets {
caster: e,
spell_id,
hits,
misses,
dest,
ammo_display_id,
seq,
});
@@ -804,6 +811,7 @@ mod tests {
None,
None,
None,
None,
&mut commands,
&index,
&casting,
+3
View File
@@ -689,6 +689,9 @@ fn writer_loop(
count,
} => w.destroy_item(bag_index, slot, count),
ClientCommand::CastSpell { spell_id, target } => w.cast_spell(spell_id, target),
ClientCommand::CastSpellAtDest { spell_id, dest } => {
w.cast_spell_at_dest(spell_id, dest)
}
ClientCommand::CancelAura { spell_id } => w.cancel_aura(spell_id),
ClientCommand::SetActionButton { button, packed } => {
w.set_action_button(button, packed)
+85 -4
View File
@@ -179,7 +179,7 @@ pub struct EffectPipelineKey {
/// The draw's blend — the whole state-variant axis (0733 §4).
blend: EffectBlend,
/// The rasterizer depth-bias constant (the decal family's coplanarity settle; 0 for
/// free-floating geometry). Three values exist ({0, 4096, 8192}), so the key space stays
/// free-floating geometry). Three values exist ({0, 8192, 32768}), so the key space stays
/// small. Nonzero ALSO selects the `DECAL_WORLD_CLIP` transform (0781): a coplanar decal
/// keeps absolute verts and runs the mesh path's `clip_from_world`, so its depth ties
/// against the drawn ground within the bias.
@@ -466,7 +466,7 @@ fn prepare_effects(
let effect_fn = draw_functions.read().id::<DrawEffects>();
// `$WOW_EFFECT_TRACE` — the lane's own phase probe (the WOW_PHASE shape, decision 0665,
// asked of effect items): during the merge walk, record each BLOB-SHADOW item (Tris
// topology + the shadow's 4096 raster bias) with its sort distance and appended index
// topology + the shadow's own raster bias) with its sort distance and appended index
// range, then log the phase totals. Splits "never pushed / never queued / merged away /
// submitted but the GPU state ate it" — the gap no pixel reading can see into.
let trace = std::env::var_os("WOW_EFFECT_TRACE").is_some();
@@ -528,7 +528,10 @@ fn prepare_effects(
}
}
let index_end = meta.indices.len() as u32;
if trace && draw.topology == EffectTopology::Tris && draw.raster_bias == 4096 {
if trace
&& draw.topology == EffectTopology::Tris
&& draw.raster_bias == crate::blob_shadow::SHADOW_RASTER_BIAS
{
trace_lines.push(format!(
" item {i}: shadow anchor {:.1?}, dist {:.1}, verts {}, indices \
{index_start}..{index_end}, tex {:?}",
@@ -795,7 +798,7 @@ mod tests {
/// 0781's premise, demonstrated in the two routes' exact arithmetic shapes: give both the
/// SAME ground vertex at WoW-scale coordinates, and the cam-relative route (0733 §2)
/// disagrees with the world-mesh route by the ORDER OF THE WHOLE 4096-unit rasterizer bias
/// disagrees with the world-mesh route by the ORDER OF THE WHOLE 0781-era 4096-unit bias
/// at close camera distances — pure arithmetic divergence spending the margin that exists
/// to settle real geometry deltas (the CPU-baked decal verts vs the GPU-transformed render
/// verts; GPU per-pipeline FMA differences push the route term higher still). The
@@ -836,4 +839,82 @@ mod tests {
unfounded"
);
}
/// 0781's named residual, sized (B131 — the director-confirmed flicker): the decal's verts
/// are CPU-baked world positions, the receiver's are GPU-transformed per frame, and the two
/// roundings displace the baked point from the drawn plane by a few ulps of the WORLD
/// COORDINATE — millimetres at city magnitudes. The depth conflict is that displacement's
/// component along the receiver's normal: a level street barely samples it (the y ulp at
/// street height is micro-scale), a *sloped* receiver takes the full horizontal ulps onto
/// its tilted normal — and the confirmed flicker walk was the Stormwind gate ramp. Model
/// the 3-ulp worst case (0781: "~1–3 ulps") at the walk's own coordinates across zoom and
/// slope, and pin the resize: the residual spends over half the 0781-era 4096 margin (with
/// the GPU's FMA-contraction noise on top, the tie is lost — the live A/B), while
/// `SHADOW_RASTER_BIAS` dominates the same worst case with ≥2× headroom.
#[test]
fn raised_bias_dominates_the_bake_residual() {
use crate::blob_shadow::SHADOW_RASTER_BIAS;
// The ramp mid-spot of the flicker walk: bevy (−wow.y, wow.z, −wow.x) of
// wow (−8843.41, 642.68, 95.92).
let ground = Vec3::new(-642.68, 95.92, 8843.41);
let clip_from_view =
Mat4::perspective_infinite_reverse_rh(std::f32::consts::FRAC_PI_4, 16.0 / 9.0, 0.1);
let ulp = |v: f32| f32::from_bits(v.to_bits() + 1) - v;
// 3 ulps per world axis, signs free — the worst displacement onto a unit normal n is
// the absolute sum.
let worst_normal_offset = |n: Vec3| {
3.0 * (n.x.abs() * ulp(ground.x)
+ n.y.abs() * ulp(ground.y)
+ n.z.abs() * ulp(ground.z))
};
let cam_dir = Vec3::new(0.35, 0.55, 0.76).normalize();
let mut worst_over_old = 0.0_f32;
let mut worst_over_new = 0.0_f32;
// Receiver grades from level street to a steep ramp, tilted along 8 azimuths.
for slope in [0.0_f32, 0.08, 0.2, 0.35] {
for az in 0..8 {
let a = az as f32 * std::f32::consts::FRAC_PI_4;
let tilt = Vec3::new(a.cos(), 0.0, a.sin());
let normal = (Vec3::Y - tilt * slope).normalize();
let delta_n = worst_normal_offset(normal);
for step in 0..80 {
let d = 1.0 + step as f32 * 0.5;
let cam = ground + cam_dir * d;
let world_from_view = Mat4::look_at_rh(cam, ground, Vec3::Y).inverse();
let view_from_world = world_from_view.inverse();
let clip_from_world = clip_from_view * view_from_world;
let depth = depth_world(&clip_from_world, ground);
// Window-depth cost of the offset along the normal, differenced in f64 so
// the measurement isn't polluted by the very f32 noise it measures.
let m = clip_from_world.as_dmat4();
let z_at = |p: bevy::math::DVec3| {
let c = m * p.extend(1.0);
c.z / c.w
};
let p = ground.as_dvec3();
let eps = 0.05;
let grad = (z_at(p + normal.as_dvec3() * eps) - z_at(p)).abs() / eps;
let conflict = delta_n as f64 * grad;
let unit = bias_unit(depth) as f64;
worst_over_old = worst_over_old.max((conflict / (4096.0 * unit)) as f32);
worst_over_new =
worst_over_new.max((conflict / (SHADOW_RASTER_BIAS as f64 * unit)) as f32);
}
}
}
eprintln!(
"bake residual worst: {worst_over_old:.3}x the 0781-era 4096 margin, \
{worst_over_new:.3}x SHADOW_RASTER_BIAS"
);
assert!(
worst_over_old > 0.5,
"the 3-ulp residual stayed far inside the 0781-era 4096 margin \
(worst {worst_over_old:.2}×) — the resize's premise would be unfounded"
);
assert!(
worst_over_new < 0.5,
"SHADOW_RASTER_BIAS leaves under 2× headroom against the 3-ulp residual \
(worst {worst_over_new:.2}×) — raise it"
);
}
}
+9 -3
View File
@@ -24,7 +24,7 @@ use avian3d::prelude::*;
use crate::assets::AssetSet;
use crate::creature_anim::{move_flags, wrap_pi, BodyTwist, MovementState};
use crate::interact::{InspectMode, WorldClick, WorldRightClick};
use crate::interact::{InspectMode, WorldClick, WorldRightClick, WorldRightPress};
use crate::net::{ClientCommand, NetCommands, SelfPlayer, TeleportMessage, WorldportMessage};
use crate::schedule::WorldStage;
use crate::ui_script::PointerOverUi;
@@ -273,10 +273,15 @@ fn control(
>,
window: Single<(&mut Window, &mut CursorOptions), With<PrimaryWindow>>,
// Clean clicks (press+release, no drag) go out here — left for the target picker, right for the
// context action (attack) — while *drags* engage the camera looks below instead. The locals hold
// context action (attack) — while *drags* engage the camera looks below instead; the third is
// the right button's raw DOWN edge (targeting's cancel, decision 0792). The locals hold
// each button's accumulated drag distance while a press is being classified (`None` = no press
// pending).
mut world_clicks: (MessageWriter<WorldClick>, MessageWriter<WorldRightClick>),
mut world_clicks: (
MessageWriter<WorldClick>,
MessageWriter<WorldRightClick>,
MessageWriter<WorldRightPress>,
),
mut click_test: (Local<Option<f32>>, Local<Option<f32>>),
// World context for the mover, bundled into one param (16-param limit): the loaded water
// surfaces (swim mode + the buoyant float, see [`swim`]), the armed transports (the
@@ -374,6 +379,7 @@ fn control(
click_consumed.0,
&mut world_clicks.0,
&mut world_clicks.1,
&mut world_clicks.2,
left_click,
right_click,
);
+12 -1
View File
@@ -13,7 +13,7 @@ use bevy::window::{CursorGrabMode, CursorOptions};
use avian3d::prelude::*;
use crate::collision::camera_query_filter;
use crate::interact::{WorldClick, WorldRightClick};
use crate::interact::{WorldClick, WorldRightClick, WorldRightPress};
use crate::model_fade::{
self_model_fade_alpha, FadeMaterials, PendingAppearFade, RenderFade, SELF_FADE_WINDOW,
};
@@ -206,9 +206,20 @@ pub(super) fn run_look_session(
click_consumed: bool,
world_click: &mut MessageWriter<WorldClick>,
world_right_click: &mut MessageWriter<WorldRightClick>,
world_right_press: &mut MessageWriter<WorldRightPress>,
left_click: &mut Option<f32>,
right_click: &mut Option<f32>,
) {
// The right button's DOWN edge, before any click-vs-drag classification — the reference's
// WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. It belongs
// to the world when the press lands in the viewport off the UI, or whenever a look session
// already owns the (hidden, locked) cursor — a right join into a left-orbit is still a world
// press. Ground-targeting's cancel reads this edge (decision 0792).
if buttons.just_pressed(MouseButton::Right)
&& (rig.look.is_some() || (cursor_in_viewport(window, camera) && !pointer_over_ui))
{
world_right_press.write(WorldRightPress);
}
// A left+right gesture is a both-button run / camera turn — never a target select. Cancel any
// pending left click-vs-drag test the instant the right button joins in, so releasing out of a
// both-button move never fires a spurious selection click.
+8
View File
@@ -61,12 +61,20 @@ pub(super) fn select_on_click(
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
occlusion: Res<PickOcclusion>,
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
ground: Res<crate::ui_action::SpellTargeting>,
) {
// Drain the frame's clicks; act only if there was one and the inspector isn't holding left-click.
let clicked = clicks.read().last().is_some();
if !clicked || inspect.enabled {
return;
}
// The ground-targeting cursor owns the click (decision 0792): the ref's terrain leg tries
// the ground commit BEFORE its select/deselect half and skips it when the commit fires
// (`0x492580`'s "otherwise"). The commit system runs after this one in the chain, so the
// mode is still readable here — selection changes not at all, in range or out.
if ground.active() {
return;
}
let (self_guid, engaged) = self_q
.single()
.map(|(g, e)| (Some(g.0), e))
+2
View File
@@ -42,6 +42,7 @@ pub(super) fn update_pick_occlusion(
mut occlusion: ResMut<PickOcclusion>,
) {
occlusion.distance = f32::INFINITY;
occlusion.point = None;
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
@@ -62,6 +63,7 @@ pub(super) fn update_pick_occlusion(
&|e| occluders.contains(e),
) {
occlusion.distance = hit.distance;
occlusion.point = Some(ray.origin + *ray.direction * hit.distance);
}
}
+23 -1
View File
@@ -50,6 +50,7 @@ mod flash;
mod highlight;
mod hover;
pub(crate) mod lock;
mod reticle;
mod ring;
mod scan;
@@ -113,12 +114,17 @@ pub(crate) struct HoveredObject {
#[derive(Resource)]
pub(crate) struct PickOcclusion {
pub(crate) distance: f32,
/// The hit itself — the world-space point `distance` names along the cursor ray, or `None`
/// with no hit (sky, mouselook, no cursor). The ground-targeting machine's hover/commit point
/// (decision 0792): the same world trace the reference's targeting position rides.
pub(crate) point: Option<Vec3>,
}
impl Default for PickOcclusion {
fn default() -> Self {
Self {
distance: f32::INFINITY,
point: None,
}
}
}
@@ -169,6 +175,7 @@ impl Plugin for TargetPlugin {
Startup,
(
ring::setup_ring,
reticle::setup_reticle,
(ring::load_factions, scan::load_creature_types).after(AssetSet::Open),
),
)
@@ -185,8 +192,22 @@ impl Plugin for TargetPlugin {
hover::update_hover,
hover::update_hovered_object,
cursor_mode::classify_cursor,
// 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,
// 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,
// 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,
click::world_right_click_payload,
click::select_on_click,
// AFTER the gated select (which holds while the mode is active): the commit
// may clear the mode, and the selection gate must have read it first.
crate::ui_action::targeting::commit_ground_cast_on_click,
click::act_on_right_click,
click::clear_target_requests,
click::target_unit_requests,
@@ -207,7 +228,8 @@ impl Plugin for TargetPlugin {
// rebuilt by `update_ring` in Update; this is a tinted copy).
.add_systems(
PostUpdate,
ring::push_ring.after(crate::particles::buffer::begin_effect_frame),
(ring::push_ring, reticle::push_reticle)
.after(crate::particles::buffer::begin_effect_frame),
);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! The **ground-targeting AoE reticle** — the terrain-projected decal the targeting cursor
//! drags across the world (decision 0797; byte-pinned in wow-re `ground-target-reticle.md`).
//!
//! For every ordinary area spell (Blizzard, Flamestrike — no object-placement effect), the
//! reference draws a **projected decal**, not a model: box = the picked ground point ± r in the
//! horizontal plane, **± 2.0 vertically** (`0x4837b0`); textures
//! `Interface\SpellShadow\Spell-Shadow-Acceptable.blp` (in range) /
//! `…-Unacceptable.blp` (out), vertex colour `0xffffffff` — the *texture* carries the
//! green/red, not a tint. It projects through **the same ground-decal projector as the
//! selection ring** (`0x6d7330 → 0x6d6fa0 → 0x6d7480` — [`crate::decal`]), axis-aligned (no
//! rotation term; the effect-0x51 placement rotate orients a GameObject *preview model*, a
//! machine we don't carry — that family of spells is a named residual).
//!
//! **Radius**: `r = min(GetCurrentCastRadius, 20.0)` (`0x6e6350`, clamp `0x4820f0`):
//! per-effect `radius + casterLevel × perLevel` over **EffectRadiusIndex[0] and [1] only**
//! (slot 2 is never read), max with candidate-1 winning ties/NaN. **Out of range forces the
//! radius to 0.0** — the decal shrinks to the 1.3888889 default *and* turns red. `r == 0` (no
//! radius rows — a dest spell with no area) also draws at the default. Class-6 spell modifiers
//! don't exist yet (the same residual as the range gate, 0792).
//!
//! **States**: in range → Acceptable at `r`; out of range → Unacceptable at the default size;
//! cursor over sky / no world hit → **nothing is drawn** (the ref resets its draw state every
//! hover pass before the pick — the "frozen at the last point" reading was refuted at the
//! bytes). Over a unit the decal draws on the ground behind it (the pick can't see units while
//! a dest-only word is up — `world-click-targeting.md` Q2).
//!
//! Named gap (shared with the blob shadow): the ref's second projection pass takes **liquid**
//! surfaces (flags `0x0f0000`) — our [`GroundDecalSurface`] set has no liquid yet, so the
//! reticle vanishes over water instead of floating on it (wow-re trap #8, half-carried).
use avian3d::prelude::Collider;
use bevy::prelude::*;
use crate::collision::GroundDecalSurface;
use crate::decal::{project_decal, DecalFrame};
use crate::net::{ObjectStore, SelfPlayer};
use crate::particles::buffer::{EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
use crate::player::WorldCamera;
use crate::target::{PickOcclusion, WorldCursor};
use crate::ui_action::{ground_cast_radius, SpellTargeting, Spells};
/// The default footprint when the radius is 0 — out of range, or a rowless spell
/// (`[0xb4b3b0] == 0.0 → 1.3888889`, the ref's literal).
const RETICLE_DEFAULT_RADIUS: f32 = 1.388_889;
/// The projection box's vertical slab: ± 2.0 around the picked point (`0x4837b0` — fixed, not
/// radius-scaled like the ring's).
const RETICLE_VERT: f32 = 2.0;
/// Same coplanarity treatment as the ring (`ring::RING_DEPTH_BIAS` rationale).
const RETICLE_DEPTH_BIAS: f32 = 8192.0;
/// The two state textures (the render-side residency gate withholds the draw until loaded).
#[derive(Resource)]
pub(super) struct ReticleAssets {
acceptable: Handle<Image>,
unacceptable: Handle<Image>,
}
/// The single reticle record — the ring's resource pattern (projection cached against a key,
/// pushed onto the effect stream per frame).
#[derive(Resource, Default)]
pub(super) struct ReticleState {
verts: Vec<EffectVertex>,
key: ReticleKey,
acceptable: bool,
shown: bool,
}
/// The projection's rebuild inputs — a still cursor costs a compare.
#[derive(Default, PartialEq, Clone, Copy)]
struct ReticleKey {
center: Vec3,
radius: f32,
surfaces: usize,
}
pub(super) fn setup_reticle(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.insert_resource(ReticleAssets {
acceptable: asset_server
.load::<Image>("mpq://interface/spellshadow/spell-shadow-acceptable.blp"),
unacceptable: asset_server
.load::<Image>("mpq://interface/spellshadow/spell-shadow-unacceptable.blp"),
});
commands.init_resource::<ReticleState>();
}
/// Place/size/state the reticle each frame while the targeting cursor is up. Runs after the
/// targeting cursor drive in the target chain: `WorldCursor.unable` IS the frame's range
/// verdict (the one `CheckGroundPointInRange` caller — the cursor and the decal state are the
/// same read in the ref).
pub(super) fn update_reticle(
targeting: Res<SpellTargeting>,
occlusion: Res<PickOcclusion>,
cursor: Res<WorldCursor>,
spells: Option<Res<Spells>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
surfaces: Query<&Collider, With<GroundDecalSurface>>,
mut state: ResMut<ReticleState>,
) {
let state = &mut *state;
let (Some(spell_id), Some(point)) = (targeting.spell(), occlusion.point) else {
// Not targeting, or the pick hit nothing (sky): nothing is drawn — the ref resets the
// draw state on every hover pass before the pick.
state.shown = false;
return;
};
let acceptable = !cursor.unable;
// Out of range forces radius 0.0 (the ref writes the global before the state fork), and a
// zero radius draws at the literal default.
let radius = if acceptable {
let level = self_store
.single()
.ok()
.and_then(|s| s.0.unit_level())
.unwrap_or(1);
ground_cast_radius(spells.as_deref(), spell_id, level)
} else {
0.0
};
let radius = if radius > 0.0 {
radius
} else {
RETICLE_DEFAULT_RADIUS
};
let key = ReticleKey {
center: point,
radius,
surfaces: surfaces.iter().count(),
};
if !(state.shown && key == state.key && state.acceptable == acceptable) {
state.verts.clear();
state.key = key;
state.acceptable = acceptable;
// Axis-aligned box (no rotation term), the fixed ±2.0 vertical slab, box → [0,1]² UVs
// (the ref's 0.5 UV bias is exactly this center mapping). Vertical fade mirrors the
// ring's treatment so a draped wall piece dims instead of smearing at full strength.
let frame = DecalFrame {
center: point,
sin: 0.0,
cos: 1.0,
min_x: -radius,
max_x: radius,
min_z: -radius,
max_z: radius,
min_y: -RETICLE_VERT,
max_y: RETICLE_VERT,
};
project_decal(
&mut state.verts,
&surfaces,
&frame,
|p| ((RETICLE_VERT - p.y.abs()) / RETICLE_VERT).clamp(0.0, 1.0),
|x, z| frame.rect_uv(x, z),
);
}
state.shown = !state.verts.is_empty();
}
/// Push the shown reticle onto the effect stream — vertex colour white (the ref writes
/// `0xffffffff`; the state picks the TEXTURE), standard alpha blend (the ref's GxRs blend-mode
/// 2), fog off, at the ring's decal rung.
pub(super) fn push_reticle(
assets: Option<Res<ReticleAssets>>,
state: Option<Res<ReticleState>>,
cam: Query<Entity, With<WorldCamera>>,
mut quads: ResMut<EffectQuads>,
) {
let (Some(assets), Some(state)) = (assets, state) else {
return;
};
let Ok(cam) = cam.single() else { return };
if !state.shown || state.verts.is_empty() {
return;
}
let texture = if state.acceptable {
assets.acceptable.id()
} else {
assets.unacceptable.id()
};
let start = quads.begin();
quads.verts.extend(state.verts.iter().map(|v| EffectVertex {
pos: v.pos,
uv: v.uv,
color: [1.0, 1.0, 1.0, v.color[3]],
}));
quads.commit_tris(
start,
EffectDrawSpec {
cam,
texture,
blend: EffectBlend::Alpha,
fog: EffectFog::Off,
anchor: state.key.center,
bias: RETICLE_DEPTH_BIAS,
raster_bias: RETICLE_DEPTH_BIAS as i32,
main_entity: Entity::PLACEHOLDER,
light: None,
},
);
}
+17
View File
@@ -52,6 +52,7 @@ pub(crate) fn send_spell_cast(
cast_errors: &mut CastErrors,
auto_repeat: &mut AutoRepeatActive,
trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens,
ground: &mut super::targeting::SpellTargeting,
) {
let now = Instant::now();
let def = spells.and_then(|s| s.catalog.get(spell_id));
@@ -74,6 +75,14 @@ pub(crate) fn send_spell_cast(
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, ecs, commands);
return;
}
// TryCast's IsTargeting leg (`6e4d62`, decision 0792): a NEW cast pressed while the
// targeting cursor is up aborts the targeting first — AbortCast in targeting mode clears
// the word, no packet — and the press proceeds down the ladder. (The SAME spell's re-press
// on the action bar never reaches here: UseAction's toggle-cancel returns at the drain.)
if ground.active() {
debug!("ui_action: cast {spell_id} supersedes the targeting cursor");
ground.clear();
}
// The local not-ready refusal (the client's `IsSpellOnCooldown 0x6e1690` gate in the cast
// path): a spell/category cooldown refuses at the source with the client's own reason 0x3c
// ("Spell is not ready yet.") — never sent, exactly like the real client. The GCD is
@@ -160,6 +169,14 @@ pub(crate) fn send_spell_cast(
) {
cast_target::CastWireTarget::SelfImplicit => None,
cast_target::CastWireTarget::Unit(guid) => Some(guid),
cast_target::CastWireTarget::GroundTargeting => {
// Enter the targeting-cursor mode's location half (decision 0792) — nothing is
// sent, nothing armed: the ref's cursor entry (`6e50c8`) runs none of the commit
// tail; the world click's commit owes the GCD and the pending arm.
debug!("ui_action: cast {spell_id} awaits its ground click — targeting cursor up");
ground.enter(spell_id);
return;
}
cast_target::CastWireTarget::Refused(reason) => {
debug!("ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})");
cast_errors.0.push((spell_id, reason));
+76 -14
View File
@@ -20,9 +20,13 @@
//! is unmodeled here (INTERIM): we refuse locally with the client's own error strings instead —
//! `0x09` "You have no target." / `0x0A` "Invalid target" — and never ship an unbindable cast.
//!
//! Deferred, refused-not-guessed: ground-target AoEs (switch enum 16 / location bits 5–6 — the
//! targeting cursor), item bits 4/14 (enchants/poisons cast *at* an item), gameobject bit 11 (the
//! OPEN_LOCK path already has its own shape), STRING bit 13.
//! The **location half of the targeting cursor is modeled** (decision 0792): switch enum 16 and
//! the bare DEST word (`Targets = 0x40`) resolve to [`CastWireTarget::GroundTargeting`], which
//! [`super::targeting`] turns into the Cast/UnableCast cursor + the world-click commit. Still
//! deferred, refused-not-guessed: the pure SOURCE word (0x20 — NPC-cast data only), item bits
//! 4/14 (enchants/poisons cast *at* an item), gameobject bit 11 (the OPEN_LOCK path already has
//! its own shape), STRING bit 13, and the *unit* hand-cursor mode (the residual-unit-word
//! machine behind the autoSelfCast stand-in above).
use benilla_formats::SpellDisplay;
use bevy::ecs::system::SystemParam;
@@ -56,6 +60,10 @@ const UNIT_BITS: u16 = TF_UNIT
pub(crate) const ERR_NO_TARGET: u8 = 0x09;
pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
/// The dest-location bit — the ground-cast wire mask (`BindLocation 0x6e60f0`'s bit-6 arm; the
/// source bit 5 completes `TargetingWantsLocation 0x6e6320`'s `0x60`, still refused below).
const TF_DEST_LOCATION: u16 = 0x0040;
/// What the wire's target block should carry for this cast.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CastWireTarget {
@@ -64,6 +72,9 @@ pub(crate) enum CastWireTarget {
/// A bound unit — mask `TARGET_FLAG_UNIT (0x2)` + this guid (possibly the player's own,
/// via the autoSelfCast fallback).
Unit(u64),
/// A ground-targeted cast (decision 0792) — no send yet: enter the targeting-cursor mode's
/// location half ([`super::targeting`]); the world click commits mask `0x40` + the point.
GroundTargeting,
/// Nothing bindable — do NOT send; surface this client error instead.
Refused(u8),
}
@@ -185,8 +196,9 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
1 => word &= !TF_EXPLICIT_GATE,
5 => word &= !TF_CORPSE_ALLY,
6 | 53 => word |= TF_UNIT_ENEMY,
// 16 (ground-target) sets the cursor-mode flag, not a word bit — the location bits
// 0x60 usually arrive via `Targets` itself; both land in the refused leg below.
// 16 (ground-target) sets the cursor-mode flag (the ref's `bl`), not a word bit — the
// location bits 0x60 usually arrive via `Targets` itself; both resolve to
// `GroundTargeting` in [`resolve_cast_target`] (decision 0792).
21 | 45 => word |= TF_UNIT_ASSIST,
23 => word |= 0x0800,
25 | 63 => word |= TF_UNIT,
@@ -276,9 +288,25 @@ pub(crate) fn resolve_cast_target(
if word == 0 {
return CastWireTarget::SelfImplicit;
}
// Bits outside the unit family (item/gameobject/location/string) have no candidate here —
// the ref's item/GO/ground binders are separate machines. Refuse rather than guess.
// Arm 16's ground fast-defer (`6e52db` sets bl, `6e535b` returns-to-cursor): a ground-arm
// spell (Flamestrike) drops into targeting-cursor mode BEFORE any candidate bind — but only
// after the word==0 immediate-commit above, whose order the ref fixes (`6e5338` precedes the
// bl test).
if def.implicit_target_a1 == 16 {
return CastWireTarget::GroundTargeting;
}
// Bits outside the unit family (item/gameobject/location/string) have no candidate here.
// The DEST-location word (Blizzard's bare `Targets = 0x40`, default switch arm) is the
// targeting cursor's location half (decision 0792) — in the ref it falls out of the failed
// bind walk into cursor mode (`6e50c8`); real 5875 data never combines location bits with
// unit bits (live spell_template sweep: `Targets & 0x60` rows are exactly 0x20 or 0x40
// alone), so deferring before the unit walk is byte-equivalent. The pure SOURCE word (0x20)
// is NPC-cast data (Aura of Fear kin), unreachable from a player's book, and keeps the
// refusal with the item/GO/string machines.
if word & !UNIT_BITS != 0 {
if word == TF_DEST_LOCATION {
return CastWireTarget::GroundTargeting;
}
return CastWireTarget::Refused(ERR_INVALID_TARGET);
}
// Candidate 1: the current selection (ArmCast's explicit-guid leg — for a player caster the
@@ -404,15 +432,49 @@ mod tests {
);
}
/// Non-unit masks (ground AoE's dest-location bit, item bits) refuse instead of shipping a
/// guess — the deferred machines named in the module docs.
/// The ground family resolves to `GroundTargeting` by BOTH routes (decision 0792): the
/// arm-16 fast-defer (Flamestrike: `Targets 0x40`, implicit 16 — and even with a selection,
/// the ref defers before any candidate bind) and the bare DEST word falling out of the bind
/// walk (Blizzard: `Targets 0x40`, implicit 28 = default arm). The word==0 immediate commit
/// still precedes the arm-16 check, as the ref orders them (`6e5338` before the bl test).
#[test]
fn ground_masks_enter_targeting_mode() {
let flamestrike = spell(0x40, 16);
assert_eq!(
resolve_cast_target(Some(&flamestrike), Some(42), Some(1), true, &rel_none()),
CastWireTarget::GroundTargeting
);
let blizzard = spell(0x40, 28);
assert_eq!(
resolve_cast_target(Some(&blizzard), None, Some(1), true, &rel_none()),
CastWireTarget::GroundTargeting
);
let self_commit_with_ground_arm = spell(0, 16);
assert_eq!(
resolve_cast_target(
Some(&self_commit_with_ground_arm),
None,
Some(1),
true,
&rel_none()
),
CastWireTarget::SelfImplicit,
"word==0 commits before the arm-16 defer — the ref's order"
);
}
/// The still-deferred non-unit masks (source-location, item bits, string) refuse instead of
/// shipping a guess — the machines named in the module docs.
#[test]
fn non_unit_masks_refuse() {
let blizzard = spell(0x40, 16);
assert_eq!(
resolve_cast_target(Some(&blizzard), Some(42), Some(1), true, &rel_none()),
CastWireTarget::Refused(ERR_INVALID_TARGET)
);
for targets in [0x20u32, 0x10, 0x2000, 0x60] {
let s = spell(targets, 0);
assert_eq!(
resolve_cast_target(Some(&s), Some(42), Some(1), true, &rel_none()),
CastWireTarget::Refused(ERR_INVALID_TARGET),
"Targets {targets:#x} must stay refused"
);
}
}
fn rel_none() -> TargetRelations<'static> {
+22 -3
View File
@@ -101,9 +101,14 @@ pub(super) fn drain_action_uses(
mut pending: ResMut<crate::ui_cast::PendingCast>,
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
// The error sinks, one tuple param (Bevy's 16-param ceiling): the reason-coded cast line
// + the by-key local line.
mut errors: (ResMut<CastErrors>, ResMut<UiErrorKeys>),
// The error sinks + the targeting mode, one tuple param (Bevy's 16-param ceiling): the
// reason-coded cast line, the by-key local line, and the ground-targeting state the SPELL
// arm's toggle-cancel and the cast path's entry both write (decision 0792).
mut errors: (
ResMut<CastErrors>,
ResMut<UiErrorKeys>,
ResMut<super::targeting::SpellTargeting>,
),
mut auto_repeat: ResMut<AutoRepeatActive>,
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
mut ecs: Commands,
@@ -160,6 +165,19 @@ pub(super) fn drain_action_uses(
}
}
Some(b) if b.kind == ACTION_KIND_SPELL => {
// UseAction's toggle-cancel (`0x4e5ee0`: `GetTargetingSpellId 0x6e48e0` +
// `StopTargeting 0x6e4900`, decision 0792): re-pressing the spell whose
// targeting cursor is up cancels the targeting instead of re-arming it —
// press-again-to-cancel, before TryCast ever runs. (A spellbook re-press stays
// the ref's abort-and-re-enter — it never passes through UseAction.)
if errors.2.spell() == Some(b.action) {
debug!(
"ui_action: cast {} re-pressed — targeting toggles off",
b.action
);
errors.2.clear();
continue;
}
debug!("ui_action: cast {} (target {:?})", b.action, selection.guid);
send_spell_cast(
b.action,
@@ -176,6 +194,7 @@ pub(super) fn drain_action_uses(
&mut errors.0,
&mut auto_repeat,
&mut trade_skill_opens,
&mut errors.2,
);
}
// An item action names an item ENTRY, not a position, so the click has to find a copy
+15
View File
@@ -44,6 +44,7 @@ mod feed;
#[cfg(test)]
mod feed_tests;
mod state;
pub(crate) mod targeting;
mod weapon_icon;
pub(crate) use cast_send::send_spell_cast;
@@ -51,6 +52,9 @@ pub(crate) use errors::{
attack_mounted_refusal, reagent_totem_refusal, ui_error_text, CastErrors, MountErrors, UiError,
UiErrorKeys,
};
// `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).
pub(crate) use targeting::{ground_cast_radius, SpellTargeting};
// `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};
@@ -204,6 +208,7 @@ impl Plugin for UiActionPlugin {
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<AutoRepeatActive>()
.init_resource::<cast_target::AutoSelfCast>()
.init_resource::<targeting::SpellTargeting>()
.add_systems(Startup, load_spells.after(AssetSet::Open))
.add_systems(
Update,
@@ -227,6 +232,16 @@ 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)
.before(UiInput),
targeting::drain_stop_targeting.after(UiInput),
),
);
}
+373
View File
@@ -0,0 +1,373 @@
//! Ground-target casting — the targeting-cursor machine's **location half** (decision 0792,
//! closing B132: "ground-targeted AOE all Invalid target").
//!
//! The reference's targeting mode IS a nonzero flag_word (`IsTargeting 0x6e48a0`); this module
//! holds benilla's mirror of that state and the systems around it, each transcribing a
//! byte-verified piece (wow-re `wave-cast.md` + `cursor-system.md` §5 + the world-click path in
//! `world-click-targeting.md`, the 0792 dispatch's synthesis):
//!
//! - **Cursor** ([`drive_targeting_cursor`]): while targeting, the world classifier is
//! pre-empted (the ref's dispatcher step 2 runs before any object resolve) — **Cast** when the
//! hovered ground point passes the range gate, **UnableCast** otherwise (`0x4820f0`'s split,
//! computed by `CheckGroundPointInRange 0x6e6810` over `GetMinMaxRange`).
//! - **Commit** ([`commit_ground_cast_on_click`]): the terrain leg's action-1 arm tries the
//! ground commit first, gated only on the word (`0x492580` → `BindLocation 0x6e60f0`): bind
//! the clicked point, and with the word cleared, send — mask `0x40` + the point in WoW coords
//! (`SendCast 0x6e54f0`), arming the pending cast + the GCD exactly like the unit send.
//! - **The ESC chain** ([`feed_targeting_to_vm`] / [`drain_stop_targeting`]): the real
//! `UIParent.lua:1490` rung (`elseif ( SpellStopTargeting() ) then`) runs in our live VM; the
//! feed pushes the state its `SpellIsTargeting`/`SpellStopTargeting` bindings read, the drain
//! commits the cancel. AbortCast in targeting mode clears the word and sends **nothing**.
//!
//! Entry and the two press-cancel shapes live in the cast path itself: the resolver yields
//! [`super::cast_target::CastWireTarget::GroundTargeting`] (arm 16 / the bare DEST 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` — [`super::drain`]).
//!
//! The click path is byte-pinned by wow-re's `world-click-targeting.md` (the 0792 dispatch's
//! answers): the terrain-leg commit `0x492580` has **no range gate and no error path** — it
//! binds and sends regardless, and the server judges range (`CheckGroundPointInRange 0x6e6810`
//! has exactly ONE caller binary-wide, the hover classifier `0x4820f0`: its verdict colours the
//! cursor and nothing else). While targeting, the pick flags come from the pending spell's mask
//! alone — for a dest-only word a unit is not pickable, so a click over one commits on the
//! ground behind it ([`crate::target::click::select_on_click`]'s gate transcribes the
//! unreachable select). Right-click cancels on the DOWN edge
//! ([`cancel_targeting_on_right_press`]); movement never cancels (`0x515090`'s explicit
//! IsTargeting-skip). The ground reticle draws in [`crate::target`]'s `reticle` module
//! (decision 0797) off [`ground_cast_radius`] + the cursor's range verdict.
use std::time::Instant;
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::SpellRange;
use crate::interact::{WorldClick, WorldRightPress};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use crate::target::{CursorKind, PickOcclusion, WorldCursor};
use super::Spells;
/// The targeting-cursor mode — benilla's `flag_word != 0` mirror for the location half: `Some`
/// while a ground-targeted cast awaits its world click. Entered by the one cast-send path
/// ([`super::send_spell_cast`]'s `GroundTargeting` arm), cleared by the commit, the two press
/// cancels, and the ESC drain.
#[derive(Resource, Default)]
pub(crate) struct SpellTargeting(Option<GroundTargeting>);
struct GroundTargeting {
spell_id: u32,
}
impl SpellTargeting {
/// `IsTargeting 0x6e48a0` — the canonical predicate.
pub(crate) fn active(&self) -> bool {
self.0.is_some()
}
/// `GetTargetingSpellId 0x6e48e0` — the spell awaiting its click, for the action bar's
/// press-again toggle.
pub(crate) fn spell(&self) -> Option<u32> {
self.0.as_ref().map(|t| t.spell_id)
}
pub(crate) fn enter(&mut self, spell_id: u32) {
self.0 = Some(GroundTargeting { spell_id });
}
pub(crate) fn clear(&mut self) {
self.0 = None;
}
}
/// `CheckGroundPointInRange 0x6e6810` — min²/max² from the spell's `SpellRange` row against the
/// squared caster↔point distance. Its ONE caller binary-wide is the hover-cursor classifier
/// (`0x4820f0` — wow-re `world-click-targeting.md` Q1's caller census): the verdict colours
/// Cast/UnableCast and nothing else. The click never consults it, so neither does ours. No row
/// (a failed DBC, an unknown spell) is permissive — the server validates every send anyway.
fn ground_point_in_range(row: Option<&SpellRange>, self_pos: Vec3, point: Vec3) -> bool {
let Some(row) = row else {
return true;
};
let dist_sq = self_pos.distance_squared(point);
if row.min > 0.0 && dist_sq < row.min * row.min {
return false;
}
dist_sq <= row.max * row.max
}
/// The targeting spell's `SpellRange` row, through the catalogs.
fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> {
let spells = spells?;
spells.ranges.get(spells.catalog.get(spell_id)?.range_index)
}
/// `GetCurrentCastRadius 0x6e6350` (wow-re `ground-target-reticle.md` B2) — the reticle's
/// radius: per-effect `radius + casterLevel × perLevel` over **EffectRadiusIndex[0] and [1]
/// only** (slot 2 is never read by the client), the max with candidate 1 winning ties/NaN,
/// clamped to 20.0 (`0x4820f0`'s `[0x804478]` literal — `min`, NaN → 20). `0.0` = no radius
/// rows; the reticle then draws at its literal default size. Class-6 spell modifiers are
/// unmodelled (the 0792 residual, same as the range gate).
pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: u32) -> f32 {
let Some(spells) = spells else { return 0.0 };
let Some(d) = spells.catalog.get(spell_id) else {
return 0.0;
};
let candidate = |slot: usize| -> f32 {
let idx = d.effect_radius_index[slot];
if idx == 0 {
return 0.0;
}
spells
.radii
.get(idx)
.map_or(0.0, |r| r.radius + level as f32 * r.per_level)
};
let (c0, c1) = (candidate(0), candidate(1));
// Strict > for candidate 0; a tie or a NaN c0 falls to candidate 1 — the byte order.
let r = if c0 > c1 { c0 } else { c1 };
r.min(20.0)
}
/// While targeting, the world cursor is the classifier's pre-empt (`0x4820f0`, cursor-system
/// §5): **Cast** over an in-range ground point, **UnableCast** out of range / too close / no
/// ground hit at all (sky, mouselook). Runs right after [`crate::target`]'s classifier in the
/// target chain and overwrites its verdict — the ref runs this branch before the object
/// classifier ever executes, and the visible result is identical.
pub(crate) fn drive_targeting_cursor(
targeting: Res<SpellTargeting>,
occlusion: Res<PickOcclusion>,
spells: Option<Res<Spells>>,
self_tf: Query<&Transform, With<SelfPlayer>>,
mut cursor: ResMut<WorldCursor>,
) {
let Some(spell_id) = targeting.spell() else {
return;
};
let in_range = match (occlusion.point, self_tf.single()) {
(Some(point), Ok(tf)) => ground_point_in_range(
range_row(spells.as_deref(), spell_id),
tf.translation,
point,
),
_ => false,
};
*cursor = WorldCursor {
kind: CursorKind::Cast,
unable: !in_range,
};
}
/// The world click's ground commit — the terrain leg's action-1 arm (`0x492580`, tried before
/// anything else the click could mean; [`crate::target::click::select_on_click`] holds its gate
/// while this mode is active, so the click neither selects nor deselects). Binds the frame's
/// pick-occlusion point and sends **unconditionally** — the leg's complete callee set has no
/// range check and no error path (wow-re `world-click-targeting.md` Q1; C2 REFUTED: the click
/// never gates on range, the server judges it, and its refusing `SMSG_CAST_RESULT` is the red
/// line) — `CMSG_CAST_SPELL` mask `0x40` + the point (WoW coords), arming the pending cast +
/// the GCD (the `SendCast 0x6e54f0` tail's two live pieces for a ground cast); the mode ends
/// with the send. No world hit (sky) → the nothing leg: no commit, mode kept.
///
/// Runs AFTER `select_on_click` in the target chain: the selection gate reads the mode's state,
/// so the commit that clears it must come later in the same frame.
pub(crate) fn commit_ground_cast_on_click(
mut clicks: MessageReader<WorldClick>,
mut targeting: ResMut<SpellTargeting>,
occlusion: Res<PickOcclusion>,
spells: Option<Res<Spells>>,
net: Res<NetCommands>,
mut pending: ResMut<crate::ui_cast::PendingCast>,
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
) {
if !targeting.active() {
// Keep the reader current so a click buffered while idle can never replay as a commit
// the frame the mode turns on.
clicks.clear();
return;
}
if clicks.read().last().is_none() {
return;
}
let Some(spell_id) = targeting.spell() else {
return;
};
let Some(point) = occlusion.point else {
// The ray hit nothing (sky) — the ref's nothing-leg has no ground commit; the mode
// stays, exactly like the UnableCast cursor said it would.
return;
};
let dest = bevy_to_wow(point);
debug!(
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2})",
dest[0], dest[1], dest[2]
);
let _ = net
.0
.send(ClientCommand::CastSpellAtDest { spell_id, dest });
let now = Instant::now();
pending.arm(spell_id, now);
if let Some(d) = spells.as_ref().and_then(|s| s.catalog.get(spell_id)) {
cooldowns.start_gcd(spell_id, d, now);
}
targeting.clear();
}
/// Right-click cancels targeting — on the **DOWN edge**, the reference's WorldFrame
/// `OnMouseDown 0x483c40` → `0x492c20`: right button ∧ `IsTargeting` → `StopTargeting
/// 0x6e4900`, no packet — and the handler returns 0, so the press keeps doing everything else
/// it did (the turn-drag, the release's context click; we consume nothing either). Byte-pinned
/// by wow-re `world-click-targeting.md` Q3, whose caller census is complete: this and the
/// ESC/UseAction/TryCast paths are the ONLY input-band cancels — no keyboard caller exists.
///
/// Two qualifications, transcribed: a held cursor payload pre-empts the cancel (`0x492b50`
/// clears the payload and returns before the WorldFrame virtuals dispatch — our payload keeps
/// its own clean-click clear in [`crate::target::click::world_right_click_payload`]); and a
/// press over a UI frame never reaches the WorldFrame — [`WorldRightPress`]'s world gate
/// transcribes the certain half of wow-re's one DEFERRED (whether a UI-frame right-click also
/// cancels is unpinned there). The `0x51`-effect placement-rotate skip (`[0xceca90]`) is
/// unmodelled along with the flag itself (named residual, 0792).
pub(crate) fn cancel_targeting_on_right_press(
mut presses: MessageReader<WorldRightPress>,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
mut targeting: ResMut<SpellTargeting>,
) {
if !targeting.active() {
// Reader hygiene, like the commit's: a press buffered while idle never replays as a
// cancel the frame the mode turns on.
presses.clear();
return;
}
if presses.read().last().is_none() || payload_held.0 {
return;
}
debug!("ui_action: targeting cancelled (right-click)");
targeting.clear();
}
/// Push the targeting state into the live VM each frame, **before** the input pass runs the ESC
/// chain — what `SpellIsTargeting()` reads and `SpellStopTargeting()` gates on.
pub(crate) fn feed_targeting_to_vm(
targeting: Res<SpellTargeting>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
if let Some(mut script) = script {
script.set_spell_targeting(targeting.active());
}
}
/// Drain the ESC chain's `SpellStopTargeting()` trigger (**after** the input pass) and clear
/// the mode — the ref's `StopTargeting 0x6e4900` → AbortCast-in-targeting: word cleared, no
/// packet.
pub(crate) fn drain_stop_targeting(
mut targeting: ResMut<SpellTargeting>,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let Some(mut script) = script else {
return;
};
if script.take_stop_targeting() {
debug!("ui_action: targeting cancelled (ESC chain)");
targeting.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The `0x6e6810` mirror: min²/max² against the squared caster↔point distance — the
/// CURSOR's verdict and nothing else (its one caller binary-wide is the hover classifier;
/// the click never asks). Permissive with no row (Blizzard's row 4 is 0–30 yd; a synthetic
/// min exercises the too-close arm the real row can't).
#[test]
fn ground_point_in_range_mirrors_check_ground_point_in_range() {
let row = |min: f32, max: f32| SpellRange { min, max, flags: 0 };
let origin = Vec3::ZERO;
let at = |d: f32| Vec3::new(d, 0.0, 0.0);
let blizzard = row(0.0, 30.0);
assert!(ground_point_in_range(Some(&blizzard), origin, at(29.9)));
assert!(!ground_point_in_range(Some(&blizzard), origin, at(30.1)));
let banded = row(8.0, 35.0);
assert!(!ground_point_in_range(Some(&banded), origin, at(5.0)));
assert!(ground_point_in_range(Some(&banded), origin, at(20.0)));
// No row → permissive (the server still validates).
assert!(ground_point_in_range(None, origin, at(500.0)));
}
/// `GetCurrentCastRadius 0x6e6350` + the `0x4820f0` clamp: slots 0/1 only (slot 2 is never
/// read), max with candidate-1 winning ties, per-level scaling, min(r, 20). Fixture rows
/// mirror the real table (row 14 = 8.0 Blizzard, row 8 = 5.0 Flamestrike).
#[test]
fn ground_cast_radius_mirrors_get_current_cast_radius() {
use benilla_formats::{SpellDisplay, SpellRadius};
use std::collections::HashMap;
let mut spells = super::super::Spells::empty_for_tests();
let display = |idx: [u32; 3]| SpellDisplay {
effect_radius_index: idx,
..SpellDisplay::default()
};
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
(10, display([14, 0, 0])),
(2120, display([8, 8, 0])),
(777, display([0, 0, 13])), // slot 2 only — the client never reads it
(778, display([90, 8, 0])), // per-level row in slot 0
(779, display([10, 0, 0])), // row 10 = 30.0 — the 20.0 clamp
]));
spells.radii = benilla_formats::SpellRadiusCatalog::from_rows(HashMap::from([
(
14,
SpellRadius {
radius: 8.0,
per_level: 0.0,
max: 0.0,
},
),
(
8,
SpellRadius {
radius: 5.0,
per_level: 0.0,
max: 0.0,
},
),
(
13,
SpellRadius {
radius: 10.0,
per_level: 0.0,
max: 0.0,
},
),
(
10,
SpellRadius {
radius: 30.0,
per_level: 0.0,
max: 0.0,
},
),
(
90,
SpellRadius {
radius: 2.0,
per_level: 0.1,
max: 0.0,
},
),
]));
let s = Some(&spells);
assert_eq!(ground_cast_radius(s, 10, 60), 8.0);
assert_eq!(ground_cast_radius(s, 2120, 60), 5.0);
// Slot 2 is invisible to the reticle — no rows in 0/1 reads 0 (→ the default size).
assert_eq!(ground_cast_radius(s, 777, 60), 0.0);
// Per-level: 2.0 + 60 × 0.1 = 8.0 beats slot 1's 5.0.
assert_eq!(ground_cast_radius(s, 778, 60), 8.0);
// The 20.0 clamp (`[0x804478]`).
assert_eq!(ground_cast_radius(s, 779, 60), 20.0);
// Unknown spell / no data at all → 0 (default size).
assert_eq!(ground_cast_radius(s, 9999, 60), 0.0);
assert_eq!(ground_cast_radius(None, 10, 60), 0.0);
}
}
+1
View File
@@ -561,6 +561,7 @@ pub(super) fn drain_chat_input(
spell_id,
hits: vec![me],
misses: Vec::new(),
dest: None,
// Dev stand-in only: a real GO's ammo block carries the
// caster's actual ammo; `/castvis` has none, so shot
// spells (`75 go`, `2480 go`) fly the Rough Arrow
+3
View File
@@ -68,6 +68,7 @@ type CastTail<'w> = (
ResMut<'w, crate::ui_action::CastErrors>,
ResMut<'w, crate::ui_action::AutoRepeatActive>,
ResMut<'w, TradeSkillOpens>,
ResMut<'w, crate::ui_action::SpellTargeting>,
);
pub(crate) struct UiCraftPlugin;
@@ -298,6 +299,7 @@ fn drain_craft(
mut cast_errors,
mut auto_repeat,
mut trade_opens,
mut ground,
) = cast_tail;
let Some(mut script) = script else {
return;
@@ -340,6 +342,7 @@ fn drain_craft(
&mut cast_errors,
&mut auto_repeat,
&mut trade_opens,
&mut ground,
);
}
None => debug!("ui_craft: DoCraft({spell_id}) — unknown spell, ignored"),
@@ -334,3 +334,65 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The SpellStopTargeting rung (`UIParent.lua:1490`, decision 0792): ESC with the ground-target
/// cursor up cancels the targeting and ONLY the targeting — after the cast rung (the artifact's
/// order), before the window close. The 1/nil returns are load-bearing exactly like the cast
/// rung's: an idle press must fall straight through both.
#[test]
fn escape_ladder_targeting_rung_after_cast_before_windows() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
// SpellIsTargeting() mirrors the pushed state — the PetFrame-family readers' predicate.
assert!(!s.eval::<bool>("return SpellIsTargeting()").unwrap_or(false));
s.set_spell_targeting(true);
assert!(s.eval::<bool>("return SpellIsTargeting()").unwrap());
// Press 1 — targeting, no cast in flight: SpellStopTargeting eats the press; the bag stays.
s.run("ToggleGameMenu()").unwrap();
assert!(
s.take_stop_targeting(),
"the stop-targeting request queued for the app's cancel"
);
assert!(
!s.take_spell_stop(),
"the cast rung answered nil — nothing casting"
);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"ESC while targeting must NOT also close windows"
);
// Press 2 — BOTH casting and targeting pushed (unreachable app state, but the chain's order
// is the artifact's law): the cast rung sits first (l.1489 before l.1490) and eats alone.
s.set_casting(true);
s.run("ToggleGameMenu()").unwrap();
assert!(s.take_spell_stop(), "the cast rung eats first");
assert!(
!s.take_stop_targeting(),
"the same press must not also cancel the targeting"
);
// Press 3 — idle (the app cleared the mode and the cast resolved): both rungs answer nil
// and the press falls through to CloseAllWindows.
s.set_casting(false);
s.set_spell_targeting(false);
s.run("ToggleGameMenu()").unwrap();
assert!(!s.take_stop_targeting(), "no stray trigger when idle");
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the idle press falls through both spell rungs to CloseAllWindows"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+2
View File
@@ -195,6 +195,7 @@ fn drain_shapeshift_casts(
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
mut ground: ResMut<crate::ui_action::SpellTargeting>,
mut ecs: Commands,
) {
let Some(mut script) = script else {
@@ -237,6 +238,7 @@ fn drain_shapeshift_casts(
&mut cast_errors,
&mut auto_repeat,
&mut trade_skill_opens,
&mut ground,
);
}
}
+2
View File
@@ -303,6 +303,7 @@ fn drain_spell_casts(
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
mut ground: ResMut<crate::ui_action::SpellTargeting>,
mut ecs: Commands,
) {
let Some(mut script) = script else {
@@ -328,6 +329,7 @@ fn drain_spell_casts(
&mut cast_errors,
&mut auto_repeat,
&mut trade_skill_opens,
&mut ground,
);
}
}
+9 -3
View File
@@ -439,8 +439,13 @@ fn drain_trade_skill(
commands: Res<NetCommands>,
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
// The cast catalog + the item cache the pre-send totem/reagent check reads (decision
// 0552), one tuple param (Bevy's 16-param ceiling).
spell_inputs: (Option<Res<Spells>>, Res<Items>),
// 0552) + the targeting mode the one cast path threads (0792), one tuple param (Bevy's
// 16-param ceiling).
spell_inputs: (
Option<Res<Spells>>,
Res<Items>,
ResMut<crate::ui_action::SpellTargeting>,
),
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
mut pending: ResMut<crate::ui_cast::PendingCast>,
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
@@ -450,7 +455,7 @@ fn drain_trade_skill(
mut opens: ResMut<TradeSkillOpens>,
mut ecs: Commands,
) {
let (spells, cast_items) = spell_inputs;
let (spells, cast_items, mut ground) = spell_inputs;
let Some(mut script) = script else {
return;
};
@@ -478,6 +483,7 @@ fn drain_trade_skill(
cast_errors,
auto_repeat,
&mut opens,
&mut ground,
);
};