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:
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user