From 56c62c759115575b648e911e2eaff1566c85031e Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 30 Jul 2026 19:40:05 +0700 Subject: [PATCH] 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 --- crates/benilla-formats/src/spell_visual.rs | 145 ++++++- crates/benilla-formats/src/spells/radius.rs | 6 + .../src/bin/benilla-world/main.rs | 16 +- .../src/bin/benilla-world/probes/groundfx.rs | 258 ++++++++++++ .../src/bin/benilla-world/probes/mod.rs | 2 + .../src/bin/benilla-world/probes/spells.rs | 90 ++++- .../src/bin/benilla-world/world.rs | 11 +- crates/benilla-protocol/src/events.rs | 8 + crates/benilla-protocol/src/events/decode.rs | 2 + crates/benilla-protocol/src/messages/mod.rs | 4 +- .../benilla-protocol/src/messages/spells.rs | 34 ++ .../src/messages/update_object/fields/mod.rs | 33 ++ .../messages/update_object/fields/tests.rs | 34 ++ crates/benilla-protocol/src/world/session.rs | 10 + .../src/world/writer/spells.rs | 11 + crates/benilla-protocol/tests/spells.rs | 47 +++ crates/benilla-ui/src/script/model.rs | 11 + crates/benilla-ui/src/script/spellbook.rs | 47 +++ crates/benilla/assets/shaders/wow_model.wgsl | 152 +++++-- crates/benilla/assets/ui/UiPanels.xml | 10 +- crates/benilla/src/blob_shadow.rs | 26 +- crates/benilla/src/char_create/mod.rs | 6 +- crates/benilla/src/char_select/mod.rs | 155 +++++++- crates/benilla/src/creature_anim.rs | 6 + .../benilla/src/creature_anim/spell_visual.rs | 28 +- .../src/creature_anim/spell_visual/tests.rs | 4 + crates/benilla/src/entities.rs | 19 +- crates/benilla/src/entities/attach/mod.rs | 4 +- crates/benilla/src/entities/dest_fx.rs | 361 +++++++++++++++++ crates/benilla/src/entities/spell_fx.rs | 6 +- crates/benilla/src/entity_shade.rs | 11 + crates/benilla/src/glue/mod.rs | 43 +- crates/benilla/src/interact.rs | 11 + crates/benilla/src/lighting/global_light.rs | 131 +++++- crates/benilla/src/model_render.rs | 6 + crates/benilla/src/net.rs | 5 + crates/benilla/src/net/apply.rs | 2 + crates/benilla/src/net/apply/spells.rs | 12 +- crates/benilla/src/net/io.rs | 3 + crates/benilla/src/particles/render.rs | 89 ++++- crates/benilla/src/player.rs | 12 +- crates/benilla/src/player/camera.rs | 13 +- crates/benilla/src/target/click.rs | 8 + crates/benilla/src/target/hover.rs | 2 + crates/benilla/src/target/mod.rs | 24 +- crates/benilla/src/target/reticle.rs | 199 ++++++++++ crates/benilla/src/ui_action/cast_send.rs | 17 + crates/benilla/src/ui_action/cast_target.rs | 90 ++++- crates/benilla/src/ui_action/drain.rs | 25 +- crates/benilla/src/ui_action/mod.rs | 15 + crates/benilla/src/ui_action/targeting.rs | 373 ++++++++++++++++++ crates/benilla/src/ui_chat/input.rs | 1 + crates/benilla/src/ui_craft.rs | 3 + crates/benilla/src/ui_script/escape_tests.rs | 62 +++ crates/benilla/src/ui_shapeshift.rs | 2 + crates/benilla/src/ui_spellbook.rs | 2 + crates/benilla/src/ui_tradeskill.rs | 12 +- 57 files changed, 2600 insertions(+), 119 deletions(-) create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs create mode 100644 crates/benilla/src/entities/dest_fx.rs create mode 100644 crates/benilla/src/target/reticle.rs create mode 100644 crates/benilla/src/ui_action/targeting.rs diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual.rs index 34f75022..2f879a03 100644 --- a/crates/benilla-formats/src/spell_visual.rs +++ b/crates/benilla-formats/src/spell_visual.rs @@ -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, + /// 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; 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 Result) -> Self { + Self { rows } + } + pub fn len(&self) -> usize { self.rows.len() } diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index f392d3ef..bcd4fb38 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -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, /// 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 diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs new file mode 100644 index 00000000..02e625da --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/groundfx.rs @@ -0,0 +1,258 @@ +//! `--groundfx `: 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, + /// 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(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs index e1d7ebfc..7b7c37ef 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs index 81522cbf..2eb0f3e5 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs @@ -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, 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!( diff --git a/crates/benilla-protocol/src/bin/benilla-world/world.rs b/crates/benilla-protocol/src/bin/benilla-world/world.rs index 50c9b12f..8adf69b5 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/world.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/world.rs @@ -74,6 +74,11 @@ pub(crate) struct World { pub(crate) item_answer: Option<(u32, Option)>, pub(crate) cast_verdict: Option<(u32, bool, Option)>, pub(crate) targeted_verdict: Option<(u32, bool, Option)>, + /// 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, + pub(crate) dest_verdict: Option<(u32, bool, Option)>, pub(crate) swings_seen: u32, pub(crate) self_moves: u32, pub(crate) tally: BTreeMap, @@ -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)); diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 9a27c9af..aca846ac 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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, + /// 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, /// 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 diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 4555e0ef..b51268e8 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -152,6 +152,7 @@ pub fn decode(packet: ServerPacket) -> Vec { 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, } } diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index e37f5204..7db82d02 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 2a50919f..95f1e4f8 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -353,6 +353,22 @@ pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec { 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 { + 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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index d6ce206c..e61caaab 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { // 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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 91718176..ce7ab88f 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -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 + ); +} diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index d93e6584..2361e2c5 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -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 diff --git a/crates/benilla-protocol/src/world/writer/spells.rs b/crates/benilla-protocol/src/world/writer/spells.rs index 30d8286e..913a70b0 100644 --- a/crates/benilla-protocol/src/world/writer/spells.rs +++ b/crates/benilla-protocol/src/world/writer/spells.rs @@ -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`). diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index 631c60af..4f0f5418 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -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(); diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index ca5fa33f..5d9a207e 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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(), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index ae6d1d70..4895c8a1 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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::().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::().expect("model app_data"); + if model.spell_targeting { + model.spell_stop_targeting = true; + Ok(Value::Integer(1)) + } else { + Ok(Value::Nil) + } + })?, + )?; + Ok(()) } diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index 785b1571..d44c96b8 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -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, model_flags: vec4, // 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, // 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, // rgb ambient; w = Mod2x scale @@ -93,26 +100,32 @@ struct WowLight { light_spec: vec4, // rgb spec color; w = shininess (terrain's — unused by the matte model path) fog_color: vec4, // rgb row-7 fog (gamma); w = enable (>0.5) fog_params: vec4, // 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, sh_c10_g: vec4, sh_c10_b: vec4, sh_c13_r: vec4, sh_c13_g: vec4, sh_c13_b: vec4, - // 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, _water: array, 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, // 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(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(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( + 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(0.0), vec3(1.0), ); diff --git a/crates/benilla/assets/ui/UiPanels.xml b/crates/benilla/assets/ui/UiPanels.xml index 0ed1add1..6a99dbe0 100644 --- a/crates/benilla/assets/ui/UiPanels.xml +++ b/crates/benilla/assets/ui/UiPanels.xml @@ -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 diff --git a/crates/benilla/src/blob_shadow.rs b/crates/benilla/src/blob_shadow.rs index fa0afcd9..b24b42b4 100644 --- a/crates/benilla/src/blob_shadow.rs +++ b/crates/benilla/src/blob_shadow.rs @@ -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, }, diff --git a/crates/benilla/src/char_create/mod.rs b/crates/benilla/src/char_create/mod.rs index e5032bf4..d718c12c 100644 --- a/crates/benilla/src/char_create/mod.rs +++ b/crates/benilla/src/char_create/mod.rs @@ -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() { diff --git a/crates/benilla/src/char_select/mod.rs b/crates/benilla/src/char_select/mod.rs index 15c7bef3..e20db3ca 100644 --- a/crates/benilla/src/char_select/mod.rs +++ b/crates/benilla/src/char_select/mod.rs @@ -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, - /// 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, /// 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=`, 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=` 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