From e4cc695f84fbee04eb13f536dbc96a05cc10c8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juozas=20Polikevi=C4=8Dius?= Date: Sun, 27 Sep 2026 03:56:21 +0300 Subject: [PATCH 01/11] A friendly or resurrection spell with no usable target brings up the targeting cursor, and autoSelfCast boots at 1.12.1's 0 (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A friendly or resurrection spell that binds nothing now enters the unit targeting cursor, as `TryCast` does for any residual word without the enemy bit (`0x6e507b`–`0x6e50ab`). `autoSelfCast` boots at its reference default of `"0"`, and its `Deviates` row is gone. - **Binding.** The cursor binds a unit through a world click, and through `SpellTargetUnit` from the unit frames (`BindTarget 0x6e5b40`). The selection never moves. - **Relation checks.** Heals and buffs ask `CanAssist 0x6066f0` instead of reaction alone, so a friendly NPC without the PvP flag is no longer assistable. That is part of #50. - **Range.** `SpellCanTargetUnit` and the cursor answer per unit through `0x6e6460`'s unit leg, including the spell's min/max range (`6e677c`–`6e6802`). A click on a unit out of range raises "Out of range." or "Target too close" and keeps the cursor armed. The reference's abort there also strands the cursor, and that is not copied. - **Your own model** joins the world pick only for a word `0x6e61a0` admits (`0x850e`, no `AttributesEx 0x80000`), and never in first person. Fixes #136 Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/cvars.rs | 10 +- crates/benilla-app/src/game_plugins.rs | 9 +- crates/benilla-app/src/spell/cast_send.rs | 21 +- crates/benilla-app/src/spell/cast_target.rs | 157 ++++-- crates/benilla-app/src/spell/mod.rs | 12 +- .../benilla-app/src/spell/targeting/cursor.rs | 100 +++- crates/benilla-app/src/spell/targeting/mod.rs | 465 +++++++++++++++++- .../benilla-app/src/spell/targeting/world.rs | 194 +++++++- crates/benilla-app/src/target/hover.rs | 182 ++++++- crates/benilla-app/src/target/mod.rs | 17 +- crates/benilla-app/src/ui_pet/drain.rs | 2 +- crates/benilla-app/src/ui_pet/mod.rs | 3 +- crates/benilla-app/src/ui_unit.rs | 2 +- crates/benilla-formats/src/spells/display.rs | 6 + crates/benilla-formats/src/spells/mod.rs | 3 + crates/benilla-formats/src/spells/ranges.rs | 5 + crates/benilla-ui/src/script/cursor.rs | 7 +- crates/benilla-ui/src/script/model.rs | 11 +- crates/benilla-ui/src/script/spellbook.rs | 14 +- crates/benilla-ui/src/script/unit/bindings.rs | 27 +- crates/benilla-ui/src/script/unit/mod.rs | 5 + crates/benilla-ui/src/script/unit/tests.rs | 50 ++ 22 files changed, 1166 insertions(+), 136 deletions(-) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index f44acc4a..c26981e3 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -234,14 +234,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // `autoSelfCast` (register site `0x6e731d`, record `[0xceac34]`, read at `0x6e53d7`; `0x870dc0` // is its name string): a friendly cast that binds nothing falls back to the caster. // `TOGGLEAUTOSELFCAST` toggles it. - deviates( - "autoSelfCast", - "1", - "0", - "with it off, an unbindable friendly cast falls into the reference's \ - targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \ - machine lands", - ), + same("autoSelfCast", "0"), // The five saved camera views and the live index, at the reference's names and default strings; // owned by [`crate::player::camera_view`]. Registered so a `SaveView` persists. same( @@ -1524,7 +1517,6 @@ mod tests { names, vec![ "SoundReverb", - "autoSelfCast", "frillDensity", "gxApi", "gxColorBits", diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 1fb86f24..c4fd6f11 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -662,12 +662,17 @@ pub(crate) mod schedule_tests { /// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`; /// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and /// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which - /// those two do not write, and a chat line commutes with their pushes. + /// those two do not write, and a chat line commutes with their pushes; + /// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast + /// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token + /// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none + /// of those drains orders against the target chain. A cast press and a unit-frame bind in one + /// frame take either order until the cast drains share a set that does. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 5_463; + const UPDATE_ACTIONABLE_CEILING: usize = 5_481; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index 1b1c8c5f..ef8068c4 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -101,6 +101,8 @@ pub(crate) enum TargetedBind { Item(u64), /// The world click's GameObject guid: a chest, door, vein or herb. Object(u64), + /// The world click's unit guid. + Unit(u64), } impl CastLadder<'_, '_> { @@ -126,6 +128,10 @@ impl CastLadder<'_, '_> { TargetedBind::Object(go_guid) => { ClientCommand::CastSpellGameObject { spell_id, go_guid } } + TargetedBind::Unit(unit_guid) => ClientCommand::CastSpell { + spell_id, + target: Some(unit_guid), + }, }, CastCommit::Item { bag_index, @@ -141,6 +147,7 @@ impl CastLadder<'_, '_> { TargetedBind::Source(src) => UseItemTarget::Source(src), TargetedBind::Item(guid) => UseItemTarget::Item(guid), TargetedBind::Object(guid) => UseItemTarget::Object(guid), + TargetedBind::Unit(guid) => UseItemTarget::Unit(guid), }, }, }; @@ -404,18 +411,7 @@ fn send_spell_cast( if let Some(d) = def { if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid { let row = spells.and_then(|s| s.ranges.get(d.range_index)); - let dist_sq = ctx - .range - .self_pos - .zip(ctx.range.target_pos) - .map(|(a, b)| a.distance_squared(b)); - if let Some(reason) = validator::cast_range_refusal( - d, - row, - ctx.range.self_reach, - ctx.range.target_reach, - dist_sq, - ) { + if let Some(reason) = ctx.range.refusal(d, row) { debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})"); cast_errors.push_local(spell_id, reason); return; @@ -723,6 +719,7 @@ mod tests { auto_self_cast: false, rel: cast_target::TargetRelations { target_store: None, + target_owner_store: None, self_store: None, factions: None, reputations: &EMPTY_REPUTATIONS, diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index 52614d9b..fd842c0a 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -9,18 +9,20 @@ //! - otherwise each bit is cleared against the selection by its relation check, then against the //! player behind `autoSelfCast` (`0x6e53d7`); only a fully cleared word commits, as a unit guid. //! - a word with item, lock, GameObject or location bits enters the targeting cursor carrying -//! the whole word: its three predicates (`0x6e6320` `& 0x60`, `0x6e6330` `& 0x4010`, `0x6e62d0` -//! `& 0x4800`) can hold at once, and the click picks the leg. +//! the whole word: its location (`0x6e6320`, `& 0x60`), item (`0x6e6330`, `& 0x4010`), +//! GameObject (`0x6e62d0`, `& 0x4800`) and unit (`0x6e6460`) predicates can hold at once, and +//! the click picks the leg. //! -//! Not built: the unit hand cursor the reference enters for a residual unit word, and the STRING -//! bit 13; both refuse locally with the client's "You have no target." or "Invalid target". +//! The unit hand cursor receives a residual unit word without the enemy bit. A word carrying +//! `0x80` instead raises the local no-target/invalid-target refusal; STRING bit 13 remains +//! unmodeled and refuses locally with "Invalid target". use benilla_formats::SpellDisplay; use bevy::ecs::system::SystemParam; use bevy::prelude::*; -use crate::net::{ObjectStore, Reputations, SelfGuid, SelfPlayer}; -use crate::target::{can_attack, ring_reaction, Factions, Selection}; +use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer}; +use crate::target::{can_assist, can_attack, Factions, Selection}; /// `TARGET_FLAG_*` bits of the client's targeting flag word (`0xcecac0`). const TF_UNIT: u16 = 0x0002; @@ -41,8 +43,7 @@ const UNIT_BITS: u16 = TF_UNIT | TF_EXPLICIT_GATE | TF_CORPSE_ALLY; -/// Client cast-failed reasons "You have no target." and "Invalid target", raised locally where -/// the reference would enter its unit targeting cursor. +/// Client cast-failed reasons used when an enemy word cannot bind. pub(crate) const ERR_NO_TARGET: u8 = 0x09; pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A; @@ -82,8 +83,7 @@ pub(crate) enum CastWireTarget { /// `IsTargeting 0x6e48a0`). Each click seam tests the word with its own mask, so one word can /// serve several: Opening and Pick Lock take a bag item or a world GameObject. Targeting(u16), - /// Do not send; show this client error. The reference raises nothing here: it enters its unit - /// targeting cursor, which is not built. + /// Do not send; show this client error. Refused(u8), /// A refusal the reference raises in its cast tail after `ArmCast` returns false (`0x6e5045`). /// That tail runs after the requirement validator `0x6094f0`, so it fires at the cursor-entry @@ -108,6 +108,8 @@ pub(crate) struct CastCandidates { #[derive(Clone, Copy)] pub(crate) struct TargetRelations<'a> { pub(crate) target_store: Option<&'a ObjectStore>, + /// The target's charmer, creator or summoner store, for `CanAssist`'s `IsPvP` owner chase. + pub(crate) target_owner_store: Option<&'a ObjectStore>, pub(crate) self_store: Option<&'a ObjectStore>, pub(crate) factions: Option<&'a Factions>, pub(crate) reputations: &'a Reputations, @@ -140,6 +142,24 @@ pub(crate) struct RangeInputs { pub(crate) target_reach: Option, } +impl RangeInputs { + /// The one range compare: the squared 3D distance against `GetMinMaxRange 0x6e3480`'s bounds, + /// "Out of range." (0x59) past max², "Target too close" (0x76) inside a nonzero min². The + /// pre-send gate (`IsTargetInRange 0x6e47b0`) and the targeting cursor's unit leg (`0x6e6460`, + /// `6e677c`–`6e6802`) both ask it. No row or no position passes; the server judges. + pub(crate) fn refusal( + &self, + def: &SpellDisplay, + row: Option<&benilla_formats::SpellRange>, + ) -> Option { + let dist_sq = self + .self_pos + .zip(self.target_pos) + .map(|(a, b)| a.distance_squared(b)); + super::validator::cast_range_refusal(def, row, self.self_reach, self.target_reach, dist_sq) + } +} + impl Default for RangeInputs { fn default() -> Self { Self { @@ -158,6 +178,7 @@ pub(crate) struct CastTargeting<'w, 's> { pub(crate) selection: Res<'w, Selection>, pub(crate) self_store: Query<'w, 's, &'static ObjectStore, With>, stores: Query<'w, 's, &'static ObjectStore>, + index: Option>, self_guid: Res<'w, SelfGuid>, auto_self_cast: Res<'w, AutoSelfCast>, factions: Option>, @@ -173,12 +194,21 @@ impl CastTargeting<'_, '_> { /// This frame's [`CastContext`]. pub(crate) fn context(&self) -> CastContext<'_> { let target_store = self.selection.target.and_then(|e| self.stores.get(e).ok()); + let target_owner_store = target_store + .and_then(|store| { + store + .0 + .unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy) + }) + .and_then(|guid| self.index.as_ref()?.0.get(&guid).copied()) + .and_then(|entity| self.stores.get(entity).ok()); CastContext { selection_guid: self.selection.guid, self_guid: self.self_guid.0, auto_self_cast: self.auto_self_cast.0, rel: TargetRelations { target_store, + target_owner_store, self_store: self.self_store.iter().next(), factions: self.factions.as_deref(), reputations: &self.reputations, @@ -212,9 +242,7 @@ impl CastTargeting<'_, '_> { const EQUIPMENT_SLOT_MAINHAND: u8 = 15; /// The `autoSelfCast` CVar (name `0x870dc0`, read at `0x6e53d7`; reference default `"0"`). -/// Deviation: defaults on, because with it off an unbindable friendly cast needs the unit -/// targeting cursor, which is not built. -#[derive(bevy::prelude::Resource)] +#[derive(bevy::prelude::Resource, Default)] pub(crate) struct AutoSelfCast(pub(crate) bool); /// `autoSelfCast`'s change callback: a flag. @@ -224,12 +252,6 @@ pub(crate) fn on_cvar(ev: On, mut auto: ResMut Self { - Self(true) - } -} - /// The flag word: `Targets`, then one arm keyed on `EffectImplicitTargetA[0]` (`0x6e525a`- /// `0x6e52ef`); every implicit target not matched below leaves the word as it is. pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 { @@ -253,18 +275,20 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 { /// `BindTarget 0x6e5b40`'s unit branch: clear every flag-word bit the candidate satisfies. /// -/// The reference asks `CanAssist 0x6066f0` for the assist bit; this uses reaction alone (>= 4). +/// The assist bit asks `CanAssist 0x6066f0`: selectable, friendly or better, and an NPC's owner +/// (or the NPC itself) PvP-enabled. This keeps friendly ambient NPCs and critters unbindable. /// The enemy bit asks `CanAttack` (`0x606980`). Party and raid (`0x606c20`, `0x606d20`) accept /// only the player; the corpse check (`0x6067d0`) is assistable with health 0 here. fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 { let mut word = word; - let reaction = ring_reaction( - rel.factions, - rel.reputations, - rel.target_store, - rel.self_store, - ); - let assist = is_self || reaction >= 4; + let assist = is_self + || can_assist( + rel.target_store, + rel.factions, + rel.reputations, + rel.self_store, + |_| rel.target_owner_store.cloned(), + ); let dead = rel .target_store .is_some_and(|s| s.0.unit_health() == Some(0)); @@ -304,6 +328,12 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 word } +/// Whether `BindTarget 0x6e5b40`'s unit arm clears the whole standing word for this unit. +/// The initial selection, a world click and `SpellTargetUnit` all use this one predicate. +pub(super) fn unit_word_binds(word: u16, is_self: bool, rel: &TargetRelations) -> bool { + word & UNIT_BITS != 0 && clear_satisfied_bits(word, is_self, rel) == 0 +} + /// The wire target for casting `def`. An unknown spell sends the selection as is, or no target /// without one, and the server validates. pub(crate) fn resolve_cast_target( @@ -361,7 +391,7 @@ pub(crate) fn resolve_cast_target( // The selection (`0xb4e2d8`, `0x6e539f`). if let Some(guid) = cand.selection { let is_self = cand.caster == Some(guid); - if clear_satisfied_bits(word, is_self, rel) == 0 { + if unit_word_binds(word, is_self, rel) { return CastWireTarget::Unit(guid); } } @@ -370,19 +400,25 @@ pub(crate) fn resolve_cast_target( if let Some(guid) = cand.caster { let self_rel = TargetRelations { target_store: rel.self_store, + target_owner_store: None, ..*rel }; - if clear_satisfied_bits(word, true, &self_rel) == 0 { + if unit_word_binds(word, true, &self_rel) { return CastWireTarget::Unit(guid); } } } - // The reference enters its unit targeting cursor here; not built, so refuse. - CastWireTarget::Refused(if cand.selection.is_some() { - ERR_INVALID_TARGET + // A standing enemy word refuses locally. Every other residual unit word proceeds to + // `BindTarget`'s hand cursor. + if word & TF_UNIT_ENEMY != 0 { + CastWireTarget::Refused(if cand.selection.is_some() { + ERR_INVALID_TARGET + } else { + ERR_NO_TARGET + }) } else { - ERR_NO_TARGET - }) + CastWireTarget::Targeting(word) + } } #[cfg(test)] @@ -406,6 +442,14 @@ mod tests { } } + #[test] + fn auto_self_cast_boots_at_the_reference_default() { + assert!( + !AutoSelfCast::default().0, + "autoSelfCast defaults to the reference's disabled state" + ); + } + #[test] fn mask_follows_the_arm_map() { assert_eq!(cast_target_mask(&spell(0, 1)), 0, "Ice Armor: self"); @@ -442,6 +486,7 @@ mod tests { let it = crate::net::ObjectStore(ObjectFields::from_pairs(&[(35, 0)])); let rel = TargetRelations { target_store: Some(&it), + target_owner_store: None, self_store: Some(&me), factions: None, reputations: &Reputations(Vec::new()), @@ -455,7 +500,8 @@ mod tests { let fireball = spell(0, 6); assert_eq!( resolve_cast_target(Some(&fireball), &cands(None, Some(1)), true, &rel), - CastWireTarget::Refused(ERR_NO_TARGET) + CastWireTarget::Refused(ERR_NO_TARGET), + "enemy words retain the no-target refusal" ); // Neutral (3) is attackable by the mixed arm's `< 4` and not assistable by `>= 4`. assert_eq!( @@ -463,6 +509,10 @@ mod tests { CastWireTarget::Unit(42) ); let intellect = spell(0, 21); + assert!( + !unit_word_binds(TF_UNIT_ASSIST, false, &rel), + "the cursor's unit binder must reject the same neutral unit as the initial cast arm" + ); assert_eq!( resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), true, &rel), CastWireTarget::Unit(1), @@ -470,8 +520,8 @@ mod tests { ); assert_eq!( resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), false, &rel), - CastWireTarget::Refused(ERR_INVALID_TARGET), - "autoSelfCast off: the fallback is gated" + CastWireTarget::Targeting(TF_UNIT_ASSIST), + "autoSelfCast off leaves the friendly word for the unit cursor" ); assert_eq!( resolve_cast_target(Some(&intellect), &cands(None, Some(1)), true, &rel), @@ -490,6 +540,38 @@ mod tests { ); } + #[test] + fn assist_word_requires_the_npc_pvp_flag_not_only_a_friendly_reaction() { + use benilla_protocol::ObjectFields; + + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let factions = Factions::from_catalog( + benilla_formats::load_faction_catalog(&mut chain).expect("FactionTemplate.dbc"), + ); + // Faction templates 35 and 1 are a real friendly pair. Field 46 is UNIT_FIELD_FLAGS. + let me = ObjectStore(ObjectFields::from_pairs(&[(35, 1), (46, 0x8)])); + let quiet = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0)])); + let pvp = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0x1000)])); + let reputations = Reputations(Vec::new()); + let rel = |target| TargetRelations { + target_store: Some(target), + target_owner_store: None, + self_store: Some(&me), + factions: Some(&factions), + reputations: &reputations, + }; + + assert!( + !unit_word_binds(TF_UNIT_ASSIST, false, &rel(&quiet)), + "a friendly ambient NPC is not assistable" + ); + assert!( + unit_word_binds(TF_UNIT_ASSIST, false, &rel(&pvp)), + "the same friendly NPC becomes assistable with UNIT_FLAG_PVP" + ); + } + /// Flamestrike (implicit 16) defers even with a selection, Blizzard (implicit 28) by its /// bare DEST word; a word of 0 still commits first (`0x6e5338`). #[test] @@ -660,6 +742,7 @@ mod tests { static EMPTY: Reputations = Reputations(Vec::new()); TargetRelations { target_store: None, + target_owner_store: None, self_store: None, factions: None, reputations: &EMPTY, diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index f3bd10fc..eefb9fec 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -7,7 +7,7 @@ use bevy::prelude::*; use crate::char_select::ClientState; -use crate::ui_script::UiInput; +use crate::ui_script::{UiFeed, UiInput}; use crate::ui_unit::UnitFeed; use benilla_world::schedule::WorldStage; @@ -32,7 +32,7 @@ pub(crate) use inflight::{ }; pub(crate) use mods::{SpellModifiers, OP_COST}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. -pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants}; +pub(crate) use targeting::{ground_cast_radius, PicksSelf, SpellTargeting, TargetingWants}; /// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a /// cast ended by a move, jump or Esc drops its cast bar the same frame. @@ -53,6 +53,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_observer(cast_target::on_cvar) .add_systems( Update, @@ -65,7 +66,12 @@ impl Plugin for SpellPlugin { .before(UnitFeed), // The state push runs before the input pass's `ToggleGameMenu` and the drain // after it, so an Esc cancel lands before next frame's cursor reads the mode. - targeting::feed_targeting_to_vm.in_set(UnitFeed), + // After the old-target clear, the pet bar's writer in the feed: `"pet"` + // resolves off the bar. + targeting::feed_targeting_to_vm + .in_set(UnitFeed) + .after(crate::ui_pet::pet_stop_on_old_target_clear), + targeting::publish_picks_self.in_set(UiFeed), targeting::drain_stop_targeting.after(UiInput), // The item-target commit (`0x495d60`): after the input pass so a bag click // binds the same frame; outside the target chain, as its clicks never reach diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index 6e575408..fbd3cfc9 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -75,12 +75,13 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: /// its ray (`0x4812c8`) and the world cursor stays grey. /// /// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`, -/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit, world-item and -/// corpse legs are unreachable here: a unit-target spell never enters targeting mode. +/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is +/// [`SpellTargeting::can_target_unit`]. The world-item and corpse legs are not built. /// /// Every seam shows the `Cast` kind, so this reads the whole-word [`SpellTargeting::spell`]. pub(crate) fn drive_targeting_cursor( targeting: Res, + unit_checks: super::UnitBindChecks, occlusion: Res, hovered: Res, hovered_object: Res, @@ -102,8 +103,8 @@ pub(crate) fn drive_targeting_cursor( }; let row = range_row(spells.as_deref(), spell_id); let me = self_tf.single().ok().map(|tf| tf.translation); - // "A GameObject is the nearest pick" is the same test the click uses - // ([`super::world::commit_object_cast_on_click`]). + // The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject + // word's nearest GameObject, else the picked unit, else the terrain. let able = if targeting.wants(TargetingWants::GameObject) && crate::target::go_is_nearest(&hovered, &hovered_object) { @@ -116,6 +117,11 @@ pub(crate) fn drive_targeting_cursor( row, me, ) + } else if let Some(unit) = hovered + .target + .filter(|_| targeting.wants(TargetingWants::Unit)) + { + targeting.can_target_unit(unit, &unit_checks) } else if targeting.wants(TargetingWants::Location) { // `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast. match (occlusion.point, me) { @@ -289,6 +295,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); world.insert_resource(PickOcclusion { distance: 10.0, point, @@ -341,4 +348,89 @@ mod tests { // A lock word that also carries DEST (`0x4840`) keeps its terrain handler off a GameObject. assert!(verdict(0x4840, Some(Vec3::ZERO), None)); } + + /// `0x6e6460`'s unit leg over a hovered unit: the relation checks, then min² ≤ d² ≤ max² + /// (`6e677c`–`6e6802`). Row 5 is 0 to 30 yd and row 114 is 8 to 35 yd, both padded by the two + /// 1.5 combat reaches: 0 to 33 and 11 to 38. + #[test] + fn the_unit_leg_is_grey_out_of_range_or_off_relation() { + use bevy::ecs::system::RunSystemOnce; + use std::collections::HashMap; + + const HEAL: u32 = 2050; + const SHOT: u32 = 75; + let verdict_for = |spell: u32, word: u16, distance: f32| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + let display = |range_index| benilla_formats::SpellDisplay { + range_index, + ..Default::default() + }; + let row = |min, max| SpellRange { min, max, flags: 0 }; + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([ + (HEAL, display(5)), + (SHOT, display(114)), + ])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([ + (5, row(0.0, 30.0)), + (114, row(8.0, 35.0)), + ])); + world.insert_resource(spells); + let empty = || crate::net::ObjectStore(benilla_protocol::ObjectFields::default()); + world.spawn(( + SelfPlayer, + Transform::default(), + GlobalTransform::default(), + empty(), + )); + let unit = world + .spawn(( + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + empty(), + )) + .id(); + world.insert_resource(crate::target::Hovered { + target: Some(unit), + guid: Some(0xF130_0000_0000_0001), + distance: 5.0, + ..Default::default() + }); + world.resource_mut::().enter( + spell, + crate::spell::CastCommit::Spell, + word, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + !world.resource::().unable + }; + let verdict = |word, distance| verdict_for(HEAL, word, distance); + + // `TARGET_FLAG_UNIT` (0x2) binds any unit, so only range decides. + assert!(verdict(0x0002, 10.0), "a valid unit in range → Cast"); + assert!( + !verdict(0x0002, 40.0), + "the same unit out of range → UnableCast" + ); + // An assist word over a unit with no faction catalog: neutral, not assistable. + assert!( + !verdict(0x0100, 10.0), + "a unit that fails the relation → UnableCast" + ); + // Inside the minimum is out of range too. + assert!(verdict_for(SHOT, 0x0002, 20.0), "inside the band → Cast"); + assert!( + !verdict_for(SHOT, 0x0002, 5.0), + "inside the minimum → UnableCast" + ); + } } diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 3481b208..cfb5dafc 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -3,18 +3,21 @@ //! word and each click seam asks it its own mask test ([`TargetingWants`]). //! //! - [`cursor`]: the hover verdict per seam. Terrain is `0x4820f0`'s `CheckGroundPointInRange -//! 0x6e6810`, a GameObject is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate `0x5f8260`, -//! then range), and a word no seam handles is UnableCast. +//! 0x6e6810`, a GameObject or a unit is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate +//! `0x5f8260` or [`SpellTargeting::can_target_unit`], then range), and a word no seam handles is +//! UnableCast. //! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation -//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`). +//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), +//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` ([`drain_spell_target_unit`]). //! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click //! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose //! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing. //! //! Every seam commits through [`crate::spell::CastLadder::commit_targeted`] (`SendCast 0x6e54f0`). -//! Neither world leg has a range gate: the click sends and the server judges range, and -//! `CheckGroundPointInRange` only colours the cursor. While targeting, the pick flags come from -//! the word alone, so a click over a unit with a dest-only word commits on the ground behind it. +//! Only the unit arm has a range gate: the terrain and GameObject clicks send and the server +//! judges range, and `CheckGroundPointInRange` only colours the cursor. While targeting, the pick +//! flags come from the word alone, so a click over a unit with a dest-only word commits on the +//! ground behind it. //! //! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]), //! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which @@ -34,11 +37,104 @@ use bevy::prelude::*; use benilla_world::interact::WorldRightPress; +/// The inputs of the cursor's two unit functions, [`SpellTargeting::can_target_unit`] and +/// [`bind_target_unit`]: the relation checks the cast arm's selection bind runs +/// ([`super::cast_target::unit_word_binds`]) and the pre-send range gate's inputs +/// ([`super::cast_target::RangeInputs`]), read as [`super::cast_target::CastTargeting`] reads them. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct UnitBindChecks<'w, 's> { + stores: Query<'w, 's, &'static crate::net::ObjectStore>, + index: Option>, + self_q: Query< + 'w, + 's, + (Entity, Option<&'static crate::net::ObjectStore>), + With, + >, + factions: Option>, + reputations: Res<'w, crate::net::Reputations>, + /// The range leg's positions: the pose the hover picks against, as last frame propagated it, + /// so the VM feed, the cursor and both binds read one pose. Never the camera's, which + /// `publish_camera_pose` rewrites mid-frame. + poses: Query<'w, 's, &'static GlobalTransform, Without>, + spells: Option>, +} + +impl UnitBindChecks<'_, '_> { + fn is_self(&self, entity: Entity) -> bool { + self.self_q + .iter() + .next() + .is_some_and(|(me, _)| me == entity) + } + + /// `BindTarget 0x6e5b40`'s relation checks: whether this unit clears the whole word. + fn relations_clear(&self, word: u16, entity: Entity) -> bool { + let target_store = self.stores.get(entity).ok(); + let target_owner_store = target_store + .and_then(|store| { + store + .0 + .unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy) + }) + .and_then(|guid| self.index.as_ref()?.0.get(&guid).copied()) + .and_then(|owner| self.stores.get(owner).ok()); + let rel = super::cast_target::TargetRelations { + target_store, + target_owner_store, + self_store: self.self_q.iter().next().and_then(|(_, store)| store), + factions: self.factions.as_deref(), + reputations: &self.reputations, + }; + super::cast_target::unit_word_binds(word, self.is_self(entity), &rel) + } + + /// The spell's min/max range against this unit, through the one compare the pre-send gate + /// asks ([`super::cast_target::RangeInputs::refusal`]); our own body is distance 0. An + /// unknown spell or a missing row passes. + fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option { + let spells = self.spells.as_deref()?; + let def = spells.catalog.get(spell_id)?; + let me = self.self_q.iter().next(); + let mut range = super::cast_target::RangeInputs { + self_pos: me + .and_then(|(e, _)| self.poses.get(e).ok()) + .map(GlobalTransform::translation), + target_pos: self + .poses + .get(entity) + .ok() + .map(GlobalTransform::translation), + target_reach: self + .stores + .get(entity) + .ok() + .map(|s| s.0.unit_combat_reach()), + ..Default::default() + }; + if let Some((_, Some(store))) = me { + range.self_reach = store.0.unit_combat_reach(); + } + range.refusal(def, spells.ranges.get(def.range_index)) + } + + /// The caster under a spell with `AttributesEx & 0x80000` (`6e6507`). + fn excluded_caster(&self, spell_id: u32, entity: Entity) -> bool { + self.is_self(entity) + && self + .spells + .as_deref() + .and_then(|s| s.catalog.get(spell_id)) + .is_some_and(|d| d.excludes_caster()) + } +} + /// A click seam's mask test on the flag_word `0xcecac0`, one per reference predicate: /// /// - `Location`: `TargetingWantsLocation 0x6e6320`, `word & 0x60`, the terrain click. /// - `Item`: `TargetingWantsItem 0x6e6330`, `word & 0x4010`, the bag and paper-doll clicks. /// - `GameObject`: `TargetingWantsGameObject 0x6e62d0`, `word & 0x4800`, the world object click. +/// - `Unit`: `SpellCanTargetUnit 0x6e6460`, the unit arm of the world click. /// /// The masks overlap on `TARGET_FLAG_LOCKED`, so a lock spell answers both the item and the /// GameObject seam; the reference settles it only at the click, where `BindTarget 0x6e5b40` picks @@ -48,10 +144,11 @@ pub(crate) enum TargetingWants { Location, Item, GameObject, + Unit, } -/// The unit-shaped bits of the flag_word, what `SpellCanTargetUnit` tests. The resolver binds or -/// refuses a unit word before it reaches the cursor, so none is set today. +/// The unit-shaped bits of the flag_word, the first gate `SpellCanTargetUnit` tests before it runs +/// the candidate through the unit arm's relation and liveness checks. const UNIT_WORD_BITS: u16 = 0x0002 | 0x0004 | 0x0008 | 0x0080 | 0x0100 | 0x0200 | 0x0400 | 0x8000; impl TargetingWants { @@ -60,11 +157,16 @@ impl TargetingWants { Self::Location => 0x0060, Self::Item => 0x4010, Self::GameObject => 0x4800, + Self::Unit => UNIT_WORD_BITS, }; word & mask != 0 } } +/// The word bits `0x6e61a0` lets the world pick take the local player for: unit, raid, party, +/// assist, the explicit gate and the ally corpse, never enemy (`0x80`) or enemy corpse (`0x200`). +const SELF_PICK_BITS: u16 = 0x850e; + /// The targeting mode, `Some` while the flag_word is nonzero. Entered by the cast-send path /// ([`super::cast_target::CastWireTarget::Targeting`]), cleared by a commit or a cancel. #[derive(Resource, Default)] @@ -96,6 +198,36 @@ impl SpellTargeting { self.0.as_ref().is_some_and(|t| wants.matches(t.word)) } + /// `SpellCanTargetUnit`'s predicate `0x6e6460`, its unit leg, with the range flag both callers + /// pass (the world hover `0x4828d0` at `48290b`, the Lua `SpellCanTargetUnit 0x6e6d00`): + /// `BindTarget`'s relation checks, the caster refused under `AttributesEx & 0x80000` + /// (`6e6507`), then min² ≤ d² ≤ max² (`6e677c`–`6e6802`), so a unit inside the minimum is out + /// too. There is no line-of-sight test. + pub(crate) fn can_target_unit(&self, entity: Entity, checks: &UnitBindChecks) -> bool { + self.0.as_ref().is_some_and(|t| { + checks.relations_clear(t.word, entity) + && !checks.excluded_caster(t.spell_id, entity) + && checks.range_refusal(t.spell_id, entity).is_none() + }) + } + + /// `0x6e61a0`: whether the world pick takes the local player, the pick flag `0x20` that + /// `0x480610` tests at `48062c`. Never outside targeting (`480638`); while targeting, a word in + /// `0x850e` whose spell lacks `AttributesEx & 0x80000` (`6e61cf`). An unknown spell passes. + pub(crate) fn picks_self(&self, spells: Option<&crate::ui_action::Spells>) -> bool { + self.0.as_ref().is_some_and(|t| { + t.word & SELF_PICK_BITS != 0 + && !spells + .and_then(|s| s.catalog.get(t.spell_id)) + .is_some_and(|d| d.excludes_caster()) + }) + } + + /// The pending cast and its standing word, whatever seam it wants. + fn pending(&self) -> Option<(u32, super::cast_send::CastCommit, u16)> { + self.0.as_ref().map(|t| (t.spell_id, t.commit, t.word)) + } + /// The pending spell when the standing word answers `wants`. A surface that draws or binds for /// one seam reads this, never [`Self::spell`], or an armed lockpick gets an AoE reticle. pub(crate) fn spell_for(&self, wants: TargetingWants) -> Option { @@ -163,6 +295,24 @@ pub(crate) fn cancel_targeting_on_right_press( targeting.clear(); } +/// [`SpellTargeting::picks_self`] as the frame began, which the world pick reads +/// ([`crate::target`]'s hover): the word the VM's `SpellIsTargeting` answers from, in a declared +/// order against every cast drain that arms or ends the cursor after the input pass. +#[derive(Resource, Default)] +pub(crate) struct PicksSelf(pub(crate) bool); + +/// Publish [`PicksSelf`] before the input pass. +pub(crate) fn publish_picks_self( + targeting: Res, + spells: Option>, + mut picks: ResMut, +) { + let now = targeting.picks_self(spells.as_deref()); + if picks.0 != now { + picks.0 = now; + } +} + /// Push the targeting state into the VM each frame, before the input pass, so a word armed last /// frame stands for this frame's clicks: the ESC chain's word, the bag and doll pickup reroute's /// item half, and `CURRENT_SPELL_CAST_CHANGED` on each edge. That event hides the enchant confirm @@ -171,6 +321,9 @@ pub(crate) fn cancel_targeting_on_right_press( /// and re-arm in one frame still counts. pub(crate) fn feed_targeting_to_vm( targeting: Res, + checks: UnitBindChecks, + tokens: crate::ui_unit::UnitTokens, + selection: Res, mut last: Local>>, script: Option>, ) { @@ -178,13 +331,12 @@ pub(crate) fn feed_targeting_to_vm( let last = last.get(&script); script.set_spell_targeting(targeting.active()); script.set_item_pick_armed(targeting.wants(TargetingWants::Item)); - // `SpellCanTargetUnit`, `0x6e6460`'s unit leg, read off the word. - script.set_spell_can_target_unit( - targeting - .0 - .as_ref() - .is_some_and(|t| t.word & UNIT_WORD_BITS != 0), - ); + // `SpellCanTargetUnit(unit)`: resolve each token and ask `0x6e6460`'s unit leg. + script.set_spell_targetable_units(crate::ui_unit::reach_tokens().filter(|token| { + tokens + .resolve(token, &selection) + .is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks)) + })); if *last != targeting.spell() { *last = targeting.spell(); script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]); @@ -207,9 +359,292 @@ pub(crate) fn drain_stop_targeting( } } +/// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and +/// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit +/// failing the relation checks binds nothing and the cursor stays up; one out of the spell's range +/// raises "Out of range." or "Target too close" (`6e6063`); otherwise the cast commits at it. The +/// player's selection never moves. +fn bind_target_unit( + ladder: &mut crate::spell::CastLadder, + checks: &UnitBindChecks, + entity: Entity, + guid: u64, +) { + let Some((spell_id, commit, word)) = ladder.ground.pending() else { + return; + }; + if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) { + return; + } + if let Some(reason) = checks.range_refusal(spell_id, entity) { + // The reference's abort here (`6e6089`) also sends CMSG_CANCEL_CAST and strands the + // cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and working. + debug!("ui_action: cast {spell_id} not bound at {guid:#x} — range ({reason:#x})"); + ladder.cast_errors.push_local(spell_id, reason); + return; + } + debug!("ui_action: cast {spell_id} committed at unit {guid:#x}"); + ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Unit(guid)); +} + +/// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the +/// usage and unknown-token errors and dropped a call made while not targeting. Here a token that +/// names no unit raises "Out of range." (0x59) and ends targeting, as the reference's abort +/// clears the word, and a unit goes to [`bind_target_unit`]. +pub(crate) fn drain_spell_target_unit( + script: Option>, + tokens: crate::ui_unit::UnitTokens, + selection: Res, + checks: UnitBindChecks, + mut ladder: crate::spell::CastLadder, +) { + let Some(mut script) = script else { + return; + }; + for token in script.take_spell_target_unit() { + // An earlier token this frame may have bound or ended the cast: not targeting, no-op. + let Some(spell_id) = ladder.ground.spell() else { + continue; + }; + match tokens.resolve(&token, &selection) { + Some((entity, guid)) => bind_target_unit(&mut ladder, &checks, entity, guid), + None => { + debug!("ui_action: SpellTargetUnit({token}) names no unit — cast {spell_id} ends"); + ladder + .cast_errors + .push_local(spell_id, super::validator::ERR_OUT_OF_RANGE); + ladder.ground.clear(); + } + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer}; + use benilla_ui::script::UiScript; + use crossbeam_channel::Receiver; + use std::collections::HashMap; + + const HEAL: u32 = 2050; + /// A spell that excludes its caster (`AttributesEx & 0x80000`). + const NOT_SELF: u32 = 2051; + /// A spell on row 114, 8 to 35 yd, which the reaches pad to 11 to 38. + const BANDED: u32 = 75; + const ME: u64 = 0x10; + const ALLY: u64 = 0xF130_0000_0000_0001; + + /// The cast ladder, the unit-token resolver and a VM, with us at the origin and an ally + /// selected at `distance`. [`HEAL`] and [`NOT_SELF`] use row 5, 0 to 30 yd, which both 1.5 + /// combat reaches pad to 33. + fn unit_world(distance: f32) -> (World, Receiver, Entity) { + let (tx, rx) = crossbeam_channel::unbounded(); + let mut world = World::new(); + world.insert_resource(NetCommands(tx)); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::(); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + let row5 = |attributes_ex| benilla_formats::SpellDisplay { + range_index: 5, + attributes_ex, + ..Default::default() + }; + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([ + (HEAL, row5(0)), + (NOT_SELF, row5(0x0008_0000)), + ( + BANDED, + benilla_formats::SpellDisplay { + range_index: 114, + ..Default::default() + }, + ), + ])); + let row = |min, max| benilla_formats::SpellRange { min, max, flags: 0 }; + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([ + (5, row(0.0, 30.0)), + (114, row(8.0, 35.0)), + ])); + world.insert_resource(spells); + let empty = || ObjectStore(benilla_protocol::ObjectFields::default()); + world.spawn((SelfPlayer, Guid(ME), GlobalTransform::default(), empty())); + let ally = world + .spawn(( + Guid(ALLY), + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + empty(), + )) + .id(); + world.insert_resource(crate::target::Selection { + target: Some(ally), + guid: Some(ALLY), + }); + let mut script = UiScript::new().expect("a VM"); + script.set_spell_targeting(true); + world.insert_non_send_resource(script); + (world, rx, ally) + } + + fn arm(world: &mut World, spell: u32, word: u16) { + world.resource_mut::().enter( + spell, + super::super::cast_send::CastCommit::Spell, + word, + ); + } + + /// Queue `SpellTargetUnit(token)` in the VM, then run the host's drain. + fn spell_target_unit(world: &mut World, token: &str) { + world + .non_send_resource_mut::() + .run(&format!("SpellTargetUnit({token:?})")) + .expect("a known token"); + world + .run_system_cached(drain_spell_target_unit) + .expect("the drain runs"); + } + + fn errors(world: &mut World) -> Vec { + std::mem::take(&mut world.resource_mut::().0) + } + + /// `0x6e6d90` past the binding's checks: a token naming no unit ends the cast with "Out of + /// range."; a unit goes to `BindTarget`, which waits on a relation failure, raises "Out of + /// range." for a unit out of range with the cursor still up, and commits otherwise. The + /// selection never moves. + #[test] + fn spell_target_unit_ends_waits_or_binds_as_the_reference() { + let fail = |reason| vec![crate::ui_action::CastFail::local(HEAL, reason)]; + + // The VM queued a token, but the host is no longer targeting: nothing. + let (mut world, rx, _) = unit_world(10.0); + spell_target_unit(&mut world, "target"); + assert!(rx.try_recv().is_err()); + assert!(errors(&mut world).is_empty()); + + // A known token naming no unit: "Out of range." and targeting ends. + let (mut world, rx, _) = unit_world(10.0); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "party1"); + assert!(rx.try_recv().is_err(), "no send"); + assert_eq!(errors(&mut world), fail(0x59)); + assert!( + !world.resource::().active(), + "the cast ends" + ); + + // A unit the relation refuses (assist, neutral with no catalog): silent, still armed. + let (mut world, rx, _) = unit_world(10.0); + arm(&mut world, HEAL, 0x0100); + spell_target_unit(&mut world, "target"); + assert!(rx.try_recv().is_err()); + assert!(errors(&mut world).is_empty(), "no error"); + assert!(world.resource::().active(), "still armed"); + + // Ourselves under a spell that excludes its caster: silent, still armed. + let (mut world, rx, _) = unit_world(10.0); + arm(&mut world, NOT_SELF, 0x0002); + spell_target_unit(&mut world, "player"); + assert!( + rx.try_recv().is_err(), + "never ourselves under AttributesEx 0x80000" + ); + assert!(errors(&mut world).is_empty(), "no error"); + assert!(world.resource::().active(), "still armed"); + + // In relation but out of range: "Out of range." and still armed. + let (mut world, rx, _) = unit_world(40.0); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "target"); + assert!(rx.try_recv().is_err()); + assert_eq!(errors(&mut world), fail(0x59)); + assert!(world.resource::().active(), "still armed"); + + // In range: the cast goes to the unit and the cursor comes down. + let (mut world, rx, ally) = unit_world(10.0); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, "target"); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!(errors(&mut world).is_empty()); + assert!(!world.resource::().active()); + let selection = world.resource::(); + assert_eq!((selection.target, selection.guid), (Some(ally), Some(ALLY))); + } + + /// `SpellCanTargetUnit` asks `0x6e6460`'s unit leg per token: nil out of range, inside the + /// minimum included, and nil for ourselves under a spell that excludes its caster. + #[test] + fn spell_can_target_unit_is_nil_out_of_range() { + let can = |distance: f32, spell: u32, token: &str| { + let (mut world, _rx, _) = unit_world(distance); + arm(&mut world, spell, 0x0002); + world + .run_system_cached(feed_targeting_to_vm) + .expect("the feed runs"); + world + .non_send_resource::() + .eval::(&format!("return SpellCanTargetUnit({token:?}) == true")) + .expect("a boolean") + }; + assert!(can(10.0, HEAL, "target"), "in range"); + assert!(!can(40.0, HEAL, "target"), "out of range answers nil"); + assert!(can(40.0, HEAL, "player"), "ourselves at distance 0"); + assert!( + !can(10.0, NOT_SELF, "player"), + "never ourselves under AttributesEx 0x80000" + ); + assert!(can(10.0, NOT_SELF, "target")); + assert!(can(20.0, BANDED, "target"), "inside the band"); + assert!( + !can(5.0, BANDED, "target"), + "inside the minimum answers nil" + ); + } + + /// `0x6e61a0`: the pick takes us only while targeting, for a word in `0x850e` whose spell + /// lacks `AttributesEx & 0x80000`; [`PicksSelf`] carries it to the picker. + #[test] + fn the_pick_takes_us_only_for_a_friendly_word_that_admits_the_caster() { + let (mut world, _rx, _) = unit_world(10.0); + let picks = |world: &mut World| { + world + .run_system_cached(publish_picks_self) + .expect("the flag publishes"); + world.resource::().0 + }; + world.init_resource::(); + assert!(!picks(&mut world), "never outside targeting"); + for word in [0x0002, 0x0004, 0x0008, 0x0100, 0x0400, 0x8000, 0x0102] { + arm(&mut world, HEAL, word); + assert!(picks(&mut world), "{word:#06x} takes us"); + } + for word in [0x0080, 0x0200, 0x0040, 0x0010, 0x4800] { + arm(&mut world, HEAL, word); + assert!(!picks(&mut world), "{word:#06x} never takes us"); + } + arm(&mut world, NOT_SELF, 0x0100); + assert!(!picks(&mut world), "a spell that excludes its caster"); + world.resource_mut::().clear(); + assert!(!picks(&mut world), "and off again when the cursor ends"); + } /// A terrain click while a poison is armed must not ship a DEST block for an item spell. #[test] diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index 6473df33..47cbe35b 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -2,12 +2,13 @@ //! the pick flags (`0x481050`) come only from the word `0xcecac0`, so the word picks the leg: //! //! - terrain (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`): [`commit_ground_cast_on_click`] -//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`): -//! [`commit_object_cast_on_click`] +//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject or +//! unit arm the picked object's type chooses: [`commit_object_cast_on_click`] //! //! A location-only word yields pick flags 3, whose `& 0x7c == 0` disables the object pick, so an -//! AoE reticle clicks through a chest. Neither leg gates on range, validity or the lock: the -//! server judges and its `SMSG_CAST_RESULT` is the refusal. +//! AoE reticle clicks through a chest. The terrain and GameObject arms gate on nothing, not range, +//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit arm +//! runs `BindTarget`'s relation and range checks ([`super::bind_target_unit`]). use bevy::prelude::*; @@ -68,21 +69,21 @@ pub(crate) fn commit_ground_cast_on_click( /// 0x493540`, whose first act while targeting is `BindTarget 0x6e5b40` and return, so the click /// never changes the player's target and never reaches the GameObject's unselectable check. /// -/// `BindTarget` picks its arm by the clicked object's typemask; the GameObject arm tests the word's -/// `0x4800`, writes wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits, -/// parks the guid (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`. A unit click -/// under a lock word binds nothing, since every unit arm tests a bit the word lacks; this is -/// [`go_is_nearest`] here. -/// -/// There is no gate before the send, not range nor the lock: the refusal is the server's -/// `SMSG_CAST_RESULT`. The right-click path ([`crate::target::click`], `0x5f33e0`) does resolve the -/// lock and can refuse locally. +/// `BindTarget` picks its arm by the clicked object's typemask. The GameObject arm, when a word in +/// `0x4800` puts GameObjects in the pick and one is the nearest hit ([`go_is_nearest`]), writes +/// wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits, parks the guid +/// (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`, with no gate before the send, +/// not range nor the lock: the refusal is the server's `SMSG_CAST_RESULT`. The right-click path +/// ([`crate::target::click`], `0x5f33e0`) does resolve the lock and can refuse locally. Otherwise +/// the picked unit goes to the unit arm ([`super::bind_target_unit`]); a unit click under a lock +/// word binds nothing there, since every unit arm tests a bit the word lacks. /// /// Runs after `select_on_click`, as the terrain commit does. pub(crate) fn commit_object_cast_on_click( mut clicks: MessageReader, // The press's pick: the object the gesture started on. press: Res, + checks: super::UnitBindChecks, mut ladder: crate::spell::CastLadder, ) { let (hovered, hovered_object) = (press.hovered, press.object); @@ -94,14 +95,18 @@ pub(crate) fn commit_object_cast_on_click( if clicks.read().last().is_none() { return; } - // `TargetingWantsGameObject 0x6e62d0`: a poison's bare `0x10` word binds nothing here. + // `TargetingWantsGameObject 0x6e62d0`: without it the pick holds no GameObject. + if !(ladder.ground.wants(TargetingWants::GameObject) + && go_is_nearest(&hovered, &hovered_object)) + { + if let Some((entity, guid)) = hovered.target.zip(hovered.guid) { + super::bind_target_unit(&mut ladder, &checks, entity, guid); + } + return; + } let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::GameObject) else { return; }; - // The object leg only wins when a GameObject is the nearest hit. - if !go_is_nearest(&hovered, &hovered_object) { - return; - } let Some(guid) = hovered_object.guid else { return; }; @@ -130,6 +135,7 @@ mod tests { world.insert_resource(NetCommands(tx)); world.init_resource::(); world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); world.init_resource::(); world.init_resource::(); world.init_resource::(); @@ -266,6 +272,158 @@ mod tests { ); } + /// A unit word waits for a valid unit click and sends that unit in the ordinary + /// `TARGET_FLAG_UNIT` spell shape. An assist word does not bind a neutral unit. + #[test] + fn a_click_on_a_hovered_unit_commits_the_hand_cursor_cast() { + const HEAL: u32 = 2050; + const ALLY: u64 = 0xF130_0000_0000_0001; + let (mut world, rx, id) = fixture(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0002, + ); + world.resource_mut::().hovered = Hovered { + target: Some(Entity::from_raw_u32(1).unwrap()), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + click(&mut world, id); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!( + !world.resource::().active(), + "the unit bind clears the hand cursor" + ); + + // With no faction catalog the target's reaction is neutral (3): an ASSIST word must stay + // armed and send nothing, exactly as when that unit was selected before the cast. + let (mut world, rx, id) = fixture(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0100, + ); + world.resource_mut::().hovered = Hovered { + target: Some(Entity::from_raw_u32(1).unwrap()), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + click(&mut world, id); + assert!( + rx.try_recv().is_err(), + "a neutral unit must not receive the heal" + ); + assert!( + world.resource::().active(), + "an invalid unit click leaves the targeting word standing" + ); + + // A unit word puts no GameObject in the pick, so a chest nearer than the unit is no + // obstacle: the unit arm still binds. + let (mut world, rx, id) = fixture(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0002, + ); + hover_go(&mut world, 2.0); + world.resource_mut::().hovered = Hovered { + target: Some(Entity::from_raw_u32(1).unwrap()), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + click(&mut world, id); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + } + + /// `BindTarget`'s range leg (`6e6063`): a unit the relation checks accept but out of the + /// spell's range raises "Out of range." and the cursor stays armed; a click in range commits. + /// Row 5 is 0 to 30 yd, padded by both 1.5 combat reaches to 33. + #[test] + fn an_out_of_range_unit_click_raises_and_keeps_the_cursor() { + use std::collections::HashMap; + const HEAL: u32 = 2050; + const ALLY: u64 = 0xF130_0000_0000_0001; + let (mut world, rx, id) = fixture(); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([( + HEAL, + benilla_formats::SpellDisplay { + range_index: 5, + ..Default::default() + }, + )])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([( + 5, + benilla_formats::SpellRange { + min: 0.0, + max: 30.0, + flags: 0, + }, + )])); + world.insert_resource(spells); + world.spawn((crate::net::SelfPlayer, GlobalTransform::default())); + let ally = world + .spawn(GlobalTransform::from_translation(Vec3::new(40.0, 0.0, 0.0))) + .id(); + world.resource_mut::().enter( + HEAL, + crate::spell::cast_send::CastCommit::Spell, + 0x0002, + ); + world.resource_mut::().hovered = Hovered { + target: Some(ally), + guid: Some(ALLY), + distance: 5.0, + ..Hovered::default() + }; + + click(&mut world, id); + assert!(rx.try_recv().is_err(), "out of range sends nothing"); + assert_eq!( + std::mem::take(&mut world.resource_mut::().0), + vec![crate::ui_action::CastFail::local(HEAL, 0x59)], + "\"Out of range.\"" + ); + assert!( + world.resource::().active(), + "the cursor stays armed" + ); + + world + .entity_mut(ally) + .insert(GlobalTransform::from_translation(Vec3::new(10.0, 0.0, 0.0))); + click(&mut world, id); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + )); + assert!(world + .resource::() + .0 + .is_empty()); + assert!(!world.resource::().active()); + } + /// Every way the object click must not commit. #[test] fn the_object_commit_holds_its_fire() { diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 508adba0..4f890139 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -83,6 +83,37 @@ pub(super) struct PickPose<'w, 's> { rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>, } +/// The local identity and whether the pick takes it, one [`SystemParam`] under the picker's param +/// limit. +#[derive(bevy::ecs::system::SystemParam)] +pub(super) struct TargetHoverState<'w> { + /// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause. + self_guid: Res<'w, crate::net::SelfGuid>, + /// `0x6e61a0`'s pick flag `0x20`, as the frame began. + picks_self: Res<'w, crate::spell::PicksSelf>, +} + +/// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while +/// hidden: first person hides them ([`crate::player::apply_self_model_fade`] at alpha 0), where the +/// reference's camera fade clears its player-visible flag `[0xc4d74c]` (`0x482c41`) and +/// `ShouldRender 0x5ec8f0` refuses the active player, so it is never picked. +#[allow(clippy::type_complexity)] // the picker's own part query, borrowed as-is +fn posed_parts<'a>( + kids: &Children, + parts: &'a Query<( + &Mesh3d, + &benilla_world::rig_palette::RigPart, + Option<&InheritedVisibility>, + )>, + is_self: bool, +) -> Vec<(&'a Mesh3d, &'a benilla_world::rig_palette::RigPart)> { + kids.iter() + .filter_map(|c| parts.get(c).ok()) + .filter(|(_, _, drawn)| !is_self || drawn.is_none_or(|v| v.get())) + .map(|(mesh, part, _)| (mesh, part)) + .collect() +} + /// The unit under the cursor, as the reference's pick (`0x7089c0`) finds it. Broad phase: the ray /// against the playing sequence's bounds sphere. Pass 1: the ray against the posed render mesh, /// nearest wins. Pass 2, the mouse pick's retry when pass 1 hit nothing anywhere: every vertex @@ -102,8 +133,8 @@ pub(super) fn update_hover( mut hovered: ResMut, mesh_assets: Res>, pose: PickPose, - // For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause. - self_guid: Res, + // Our own body joins the pick only while a targeting word takes it (`0x6e61a0`). + target_state: TargetHoverState, // Last frame's pick, which outranks everything in pass 2 (the reference's anti-flicker cache). mut last_pick: Local>, // Unit roots. An undrawn body is not in the reference's draw list, so it takes no mouseover, @@ -111,6 +142,7 @@ pub(super) fn update_hover( roots: Query< ( Entity, + Has, &GlobalTransform, &NetEntity, Option<&ModelAnimations>, @@ -121,11 +153,15 @@ pub(super) fn update_hover( Option<&InheritedVisibility>, Option<&HeldAttached>, ), - (With, Without), + With, >, // The part children of every pick model root: body, worn items and mount. child_sets: Query<&Children>, - parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>, + parts: Query<( + &Mesh3d, + &benilla_world::rig_palette::RigPart, + Option<&InheritedVisibility>, + )>, // The box fallback: every `CreaturePickPart`, the fallback cube included. meshes: Query< ( @@ -137,7 +173,7 @@ pub(super) fn update_hover( With, >, // The pick's guid and kind; the kind decides which slot of `Hovered` a hit lands in. - units: Query<(&Guid, &NetEntity), Without>, + units: Query<(&Guid, &NetEntity)>, ) { hovered.target = None; hovered.guid = None; @@ -178,7 +214,12 @@ pub(super) fn update_hover( let mut candidates: Vec<(Entity, u8, Vec>, Vec)> = Vec::new(); // Units with skinned parts: out of the box fallback even when the broad phase rejects them. let mut faithful: HashSet = HashSet::new(); - for (entity, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots { + let picks_self = target_state.picks_self.0; + for (entity, is_self, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots + { + if is_self && !picks_self { + continue; + } // Units, players and corpses: the reference picks every CGObject in one trace and // switches on type at the end, below. if !matches!( @@ -241,7 +282,7 @@ pub(super) fn update_hover( let Ok(kids) = child_sets.get(root) else { continue; }; - let sk: Vec<_> = kids.iter().filter_map(|c| parts.get(c).ok()).collect(); + let sk = posed_parts(kids, &parts, is_self); let Some(palette) = palette_of(&sk) else { continue; }; @@ -280,6 +321,9 @@ pub(super) fn update_hover( if faithful.contains(&parent) { continue; // posed-mesh-tested above } + if !picks_self && roots.get(parent).is_ok_and(|root| root.1) { + continue; + } // The same kinds as the posed pick. A bone pile lands here: its corpse model has no // skeleton, so only this box test can pick it. let Ok((_, parent_net)) = units.get(parent) else { @@ -331,13 +375,13 @@ pub(super) fn update_hover( if net.kind == EntityKind::Corpse { hovered.corpse = Some(entity); hovered.corpse_guid = Some(guid.0); - } else if selectable_pick(entity, &roots, self_guid.0) { + } else if selectable_pick(entity, &roots, target_state.self_guid.0) { hovered.target = Some(entity); hovered.guid = Some(guid.0); } else { hovered.refused = true; } - } else if selectable_pick(entity, &roots, self_guid.0) { + } else if selectable_pick(entity, &roots, target_state.self_guid.0) { hovered.target = Some(entity); } else { hovered.refused = true; @@ -364,6 +408,7 @@ fn selectable_pick( roots: &Query< ( Entity, + Has, &GlobalTransform, &NetEntity, Option<&ModelAnimations>, @@ -374,11 +419,11 @@ fn selectable_pick( Option<&InheritedVisibility>, Option<&HeldAttached>, ), - (With, Without), + With, >, self_guid: Option, ) -> bool { - let store = roots.get(entity).ok().and_then(|r| r.5); + let store = roots.get(entity).ok().and_then(|r| r.6); super::relations::is_selectable(store, self_guid) } @@ -766,6 +811,121 @@ mod tests { assert_eq!(resolved[4], net, "the net entity resolves to itself"); } + /// First person hides our own parts (`Visibility::Hidden`), and the posed pick drops them, as + /// `ShouldRender 0x5ec8f0` refuses the active player then; anyone else's stay the root's call. + #[test] + fn our_hidden_parts_leave_the_posed_pick() { + let mut world = World::new(); + let root = world.spawn_empty().id(); + let rig = world.spawn_empty().id(); + let part = |world: &mut World, drawn| { + world + .spawn(( + ChildOf(root), + Mesh3d(Handle::default()), + benilla_world::rig_palette::RigPart(rig), + drawn, + )) + .id() + }; + let shown = part(&mut world, InheritedVisibility::VISIBLE); + part(&mut world, InheritedVisibility::HIDDEN); + let count = move |is_self: bool| { + move |kids: Query<&Children>, + parts: Query<( + &Mesh3d, + &benilla_world::rig_palette::RigPart, + Option<&InheritedVisibility>, + )>| { + posed_parts(kids.get(root).expect("the parts"), &parts, is_self).len() + } + }; + assert_eq!(world.run_system_once(count(false)).unwrap(), 2); + assert_eq!( + world.run_system_once(count(true)).unwrap(), + 1, + "ours: only the drawn part" + ); + world.entity_mut(shown).insert(InheritedVisibility::HIDDEN); + assert_eq!( + world.run_system_once(count(true)).unwrap(), + 0, + "first person: nothing of ours to pick" + ); + } + + /// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes + /// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts. + #[test] + fn our_body_joins_the_pick_only_while_the_word_takes_it() { + use bevy::camera::RenderTargetInfo; + use bevy::window::WindowResolution; + + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(PickOcclusion { + distance: f32::INFINITY, + point: None, + }); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::(); + world.insert_resource(crate::net::SelfGuid(Some(1))); + world.init_resource::(); + // Ten yards up the Z axis, looking down it through a 100-pixel square. + let mut camera = Camera::default(); + camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1); + camera.computed.target_info = Some(RenderTargetInfo { + physical_size: UVec2::splat(100), + scale_factor: 1.0, + }); + world.spawn(( + camera, + GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)), + WorldCamera, + )); + let mut window = Window { + resolution: WindowResolution::new(100, 100), + ..Default::default() + }; + window.set_cursor_position(Some(Vec2::splat(50.0))); + world.spawn((window, PrimaryWindow)); + // Us, at the origin under the cursor, with one drawn pick box. + let me = world + .spawn(( + SelfPlayer, + Guid(1), + NetEntity { + kind: EntityKind::Player, + display_id: None, + scale: 1.0, + }, + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + )) + .id(); + world.spawn(( + ChildOf(me), + CreaturePickPart, + Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)), + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + )); + let hover = |world: &mut World, picks: bool| { + world.resource_mut::().0 = picks; + world + .run_system_cached(update_hover) + .expect("the pick runs"); + world.resource::().target + }; + + assert_eq!(hover(&mut world, false), None, "not ours to pick"); + assert_eq!(hover(&mut world, true), Some(me), "a word that takes us"); + assert_eq!(hover(&mut world, false), None, "and not once it ends"); + } + /// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe, /// which is its whole pick surface. #[test] diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 5e54c3af..9ffe778d 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -310,13 +310,18 @@ impl Plugin for TargetPlugin { crate::spell::targeting::commit_object_cast_on_click, click::act_on_right_click, click::clear_target_requests, - // The unit-token asks (`TargetUnit`, `AssistUnit`, `TargetLastEnemy`: one - // drain, as the reference has one `0x489a40`) and `DropItemOnUnit`'s pet leg, - // independent of each other. + // The UI's unit-token drains. `SpellTargetUnit` first: it binds the cursor's + // cast and never moves the selection. Then the selection asks (`TargetUnit`, + // `AssistUnit`, `TargetLastEnemy`: one drain, as the reference has one + // `0x489a40`) and `DropItemOnUnit`'s pet leg, independent of each other. ( - click::selection_requests, - crate::ui_action::drop_item::drop_item_on_unit, - ), + crate::spell::targeting::drain_spell_target_unit, + ( + click::selection_requests, + crate::ui_action::drop_item::drop_item_on_unit, + ), + ) + .chain(), // The by-name asks: `/target`, the Lua `TargetByName` and `/assist` commit // through `scan::commit`; `/follow` hands its subject to `crate::player`. ( diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index afa99cb2..d5b8edcc 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -202,7 +202,7 @@ pub(super) fn old_target_cleared(previous: Option, now: Option) -> boo /// The old-target clear's `PetStopAttack` (`0x493910`, at `0x493a18` when `0x5ee5a0` finds a /// possessed unit at `0x493a0f`). It sits before the notify branch, so every selection writer runs /// it; a re-select runs no clear (`0x493540`). -pub(super) fn pet_stop_on_old_target_clear( +pub(crate) fn pet_stop_on_old_target_clear( selection: Res, mut previous: Local>, mut bar: ResMut, diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index 81915baa..d0f60d7d 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -19,7 +19,8 @@ mod net; mod unit; use bar::feed_pet_bar; -use drain::{drain_pet_actions, pet_stop_on_old_target_clear}; +use drain::drain_pet_actions; +pub(crate) use drain::pet_stop_on_old_target_clear; use menu::{drain_pet_menu, feed_pet_menu}; use unit::feed_pet_unit; diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 87edbeac..5132dd48 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -624,7 +624,7 @@ impl UnitTokens<'_, '_> { } /// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0). -fn reach_tokens() -> impl Iterator { +pub(crate) fn reach_tokens() -> impl Iterator { ["player", "target", "targettarget", "mouseover", "pet"] .into_iter() .chain(crate::ui_party::PARTY_TOKENS) diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index a6d69f04..d99413e6 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -366,6 +366,12 @@ impl SpellDisplay { self.attributes_ex3 & ATTR_EX3_NO_CHANNEL_BAR != 0 } + /// `AttributesEx & 0x80000`: the targeting cursor never takes the caster, neither the world + /// pick (`0x6e61a0` at `6e61cf`) nor `SpellCanTargetUnit`'s unit leg (`0x6e6460` at `6e6507`). + pub fn excludes_caster(&self) -> bool { + self.attributes_ex & ATTR_EX_EXCLUDE_CASTER != 0 + } + /// `AttributesEx & 0x2000_0000` (`0x6e759a`): the channel bar shows the spell's own name /// (`0x6e75a9`), else `CHANNELING` (`0x6e75bc`); nine channels set it, Fishing among them. pub fn channel_bar_own_name(&self) -> bool { diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 21fef33f..b127fd33 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -197,6 +197,9 @@ const ATTR_DO_NOT_DISPLAY: u32 = 0x80; pub const SPELL_ATTR_IS_TRADESKILL: u32 = 0x20; /// `SPELL_ATTR_EX_NO_AURA_ICON`: hidden from the aura bar, as all three warrior stances are. const ATTR_EX_NO_AURA_ICON: u32 = 0x1000_0000; +/// `SPELL_ATTR_EX_EXCLUDE_CASTER` (vmangos `SpellDefines.h:851`): the targeting cursor never takes +/// the caster (`0x6e61cf`, `0x6e6507`). +const ATTR_EX_EXCLUDE_CASTER: u32 = 0x0008_0000; /// `SPELL_EFFECT_ATTACK`: only 6603 "Attack" has it, but the client tests the effect, not the id. const SPELL_EFFECT_ATTACK: u32 = 78; diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs index ed3737b5..d8eec939 100644 --- a/crates/benilla-formats/src/spells/ranges.rs +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -92,6 +92,11 @@ impl SpellRangeCatalog { self.ranges.get(&index) } + /// Fixture constructor for tests; the live path is [`load_spell_ranges`]. + pub fn from_rows(ranges: HashMap) -> Self { + Self { ranges } + } + pub fn len(&self) -> usize { self.ranges.len() } diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 4f5a3f62..281e3cab 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -403,9 +403,10 @@ impl super::UiScript { self.model_mut().item_pick_armed = armed; } - /// Push what `SpellCanTargetUnit` answers: whether the armed targeting cast can take a unit. - pub fn set_spell_can_target_unit(&mut self, can: bool) { - self.model_mut().spell_can_target_unit = can; + /// Push the unit tokens for which the armed word's unit checks all pass. + pub fn set_spell_targetable_units<'a>(&mut self, tokens: impl IntoIterator) { + self.model_mut().spell_targetable_units = + tokens.into_iter().map(str::to_ascii_lowercase).collect(); } /// Drain the `(bag, slot)` picks since the last call; a paper-doll pick reports as diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 5a0eb9f6..d1d1db02 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -243,6 +243,8 @@ pub(crate) struct Model { pub(crate) target_clear: bool, /// `DropItemOnUnit` tokens (`0x48d960`), gated by the app; a refusal silently keeps the item. pub(crate) drop_item_on_unit: Vec, + /// `SpellTargetUnit` tokens: a unit frame binds the spell currently waiting on the cursor. + pub(crate) spell_target_unit: Vec, /// Channels the server confirmed, in join order: the numbers `GetChannelName` answers. pub(crate) joined_channels: Vec>, @@ -432,9 +434,9 @@ pub(crate) struct Model { /// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`, /// whose nil the ESC chain falls through on (`UIParent.lua:1490`). pub(crate) spell_targeting: bool, - /// `SpellCanTargetUnit` (`0x6e6d00`, `0x6e6460`): false while targeting models only location, - /// item and object words; a unit-target spell resolves or refuses without entering targeting. - pub(crate) spell_can_target_unit: bool, + /// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit + /// frames before they call `SpellTargetUnit`. + pub(crate) spell_targetable_units: HashSet, /// `SpellStopTargeting()` fired while targeting: the ESC targeting cancel. pub(crate) spell_stop_targeting: bool, @@ -1061,6 +1063,7 @@ impl Model { target_nearest_friend_requests: Vec::new(), target_by_name_requests: Vec::new(), drop_item_on_unit: Vec::new(), + spell_target_unit: Vec::new(), target_clear: false, joined_channels: Vec::new(), party: party::PartyState::default(), @@ -1148,7 +1151,7 @@ impl Model { casting: false, spell_stop: false, spell_targeting: false, - spell_can_target_unit: false, + spell_targetable_units: HashSet::new(), spell_stop_targeting: false, talents: super::talent::TalentUiState::default(), talent_learns: Vec::new(), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 78f2932b..58fb14b1 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -596,14 +596,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the targeting - // word can take the unit. The token is not read: no word benilla can arm (location, item, - // gameobject) takes a unit, and unit words are not built. + // SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the standing + // word clears against that resolved unit. g.set( "SpellCanTargetUnit", - lua.create_function(|lua, _unit: Option| { + lua.create_function(|lua, unit: Option| { let model = lua.app_data_ref::().expect("model app_data"); - if model.spell_can_target_unit { + let can = unit.is_some_and(|unit| { + model + .spell_targetable_units + .contains(&unit.to_ascii_lowercase()) + }); + if can { Ok(Value::Boolean(true)) } else { Ok(Value::Nil) diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 4f2782fc..9e4b8ff3 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -1079,12 +1079,31 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // SpellTargetUnit(unit): a no-op. A unit-target spell never enters targeting mode here, and for - // the location, item and gameobject modes this client arms, the reference's `BindTarget` - // (`0x6e5b40`) rejects a unit too. Stock `PetFrame_OnClick` calls it, so it must exist. + // SpellTargetUnit(unit) (`0x6e6d90`): an argument that is not a string or a number raises + // `Usage:`; while not targeting the call is a silent no-op, before the token is read; an + // unknown token then raises `Unknown unit name`. The token queues for the host, which ends + // targeting with "Out of range." when it names no unit and otherwise runs `BindTarget` + // (`0x6e5b10` → `0x6e5b40`). g.set( "SpellTargetUnit", - lua.create_function(|_, _token: Option| Ok(()))?, + lua.create_function(|lua, token: Value| { + let token = crate::script::binding_abi::string_arg( + lua, + token, + r#"Usage: SpellTargetUnit("unit")"#, + )?; + if !lua + .app_data_ref::() + .expect("model app_data") + .spell_targeting + { + return Ok(()); + } + check_unit_token(&Some(token.clone()))?; + let mut model = lua.app_data_mut::().expect("model app_data"); + model.spell_target_unit.push(token); + Ok(()) + })?, )?; // ClearTarget(): 1 when it cleared a target, nil when there was none, which `ToggleGameMenu`'s diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index de589077..b141be55 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -411,6 +411,11 @@ impl super::UiScript { pub fn take_drop_item_on_unit(&mut self) -> Vec { std::mem::take(&mut self.model_mut().drop_item_on_unit) } + + /// Drain `SpellTargetUnit` calls for the host's pending unit-target spell binder. + pub fn take_spell_target_unit(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().spell_target_unit) + } } /// The non-`"player"` token of a two-unit call such as `UnitIsEnemy(a, b)`, which the snapshot diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 3b482b0c..70ff364e 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -23,6 +23,56 @@ fn player() -> UnitState { } } +/// `0x6e6d90`'s order: the `Usage:` check, the not-targeting no-op, the token check, the queue. +#[test] +fn spell_target_unit_checks_in_the_references_order() { + let mut s = UiScript::new().unwrap(); + let usage = |s: &mut UiScript, call: &str| { + let err = s.run(call).expect_err(call).to_string(); + assert!( + err.contains(r#"Usage: SpellTargetUnit("unit")"#), + "{call}: {err}" + ); + }; + + // Not targeting: the argument is still type-checked, then nothing, not even the token check. + usage(&mut s, "SpellTargetUnit({})"); + usage(&mut s, "SpellTargetUnit()"); + s.run(r#"SpellTargetUnit("not-a-unit") SpellTargetUnit("player")"#) + .expect("a bad token is no error while not targeting"); + assert!(s.take_spell_target_unit().is_empty(), "and nothing queues"); + + // Targeting: an unknown token raises, a known one queues, resolved or not. + s.set_spell_targeting(true); + usage(&mut s, "SpellTargetUnit(nil)"); + let err = s + .run(r#"SpellTargetUnit("not-a-unit")"#) + .expect_err("an unknown token while targeting") + .to_string(); + assert!(err.contains("Unknown unit name: not-a-unit"), "{err}"); + s.run(r#"SpellTargetUnit("player") SpellTargetUnit("party1")"#) + .unwrap(); + assert_eq!( + s.take_spell_target_unit(), + vec!["player".to_string(), "party1".to_string()] + ); +} + +#[test] +fn spell_can_target_unit_answers_per_validated_token() { + let mut s = UiScript::new().unwrap(); + s.set_spell_targetable_units(["player", "party1"]); + assert!(s + .eval::(r#"return SpellCanTargetUnit("player") == true"#) + .unwrap()); + assert!(s + .eval::(r#"return SpellCanTargetUnit("PARTY1") == true"#) + .unwrap()); + assert!(s + .eval::(r#"return SpellCanTargetUnit("target") == nil"#) + .unwrap()); +} + #[test] fn unit_reaction_reports_the_scale_value_or_nil() { let mut s = UiScript::new().unwrap(); From a5cae4eb40b216e1ec84da30d56fd7c2edaed00f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:48:12 +0700 Subject: [PATCH 02/11] =?UTF-8?q?The=20build=20line=20names=20its=20releas?= =?UTF-8?q?e:=20benilla=20build=200.2.0=20=C2=B7=20,=20and=200.2.0+N?= =?UTF-8?q?=20for=20a=20commit=20N=20past=20its=20tag=20(#452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup line, crash reports and the debug panel now name the release a build is, so a bug report carries it: `benilla build 0.2.0 · b17be27 · 2026-09-27 · release` on a release, `0.2.0+12 · …` twelve commits past its tag. - `benilla-buildstamp` also stamps `git describe --tags --long --match 'v[0-9]*'` and watches `packed-refs` and `refs/tags`, so a fetched tag restamps without a new commit. - `BuildId` carries the workspace version and the describe. `release()` reads the version, with `+N` when the describe's tag is that version's; with no tag in reach (a source archive, a shallow clone) or the previous version's tag (the release commit before its tag exists) it reads the version alone. - The bug template's example line shows the new form. Checked: the four new `build_id` tests fail with the distance parser broken and pass restored; the describe command in a scratch clone reads empty with no tag, `v0.1.0-443-g…` over a launch tag, `v0.2.0-0-g…` on the release commit, and skips tags that are not `v`-numbered; the build script's output carries the new watches and variable. Gates green on macOS; no platform seam touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- crates/benilla-app/src/crash.rs | 4 +- crates/benilla-buildstamp/src/lib.rs | 27 ++++--- crates/benilla-world/src/build_id.rs | 117 ++++++++++++++++++++++++--- crates/benilla-worldview/src/main.rs | 2 + crates/benilla/src/main.rs | 2 + 6 files changed, 132 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index e27a53a9..1f99894b 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -22,4 +22,4 @@ body: attributes: label: Build and setup description: The `benilla build` line from the start of the terminal output, your OS, and the server you play on. - placeholder: "benilla build 64cca13 · 2026-09-24 · release, macOS 15, vmangos" + placeholder: "benilla build 0.2.0 · 64cca13 · 2026-09-24 · release, macOS 15, vmangos" diff --git a/crates/benilla-app/src/crash.rs b/crates/benilla-app/src/crash.rs index d1c26422..eb17c7ff 100644 --- a/crates/benilla-app/src/crash.rs +++ b/crates/benilla-app/src/crash.rs @@ -133,6 +133,8 @@ mod tests { use super::*; const BUILD: BuildId = BuildId { + version: "0.2.0", + describe: "v0.2.0-3-g0123456", sha: "0123456789abcdef0123456789abcdef01234567", short: "0123456", date: "2026-09-16", @@ -154,7 +156,7 @@ mod tests { }); for needle in [ "sha 0123456789abcdef0123456789abcdef01234567", - "0123456 · 2026-09-16 · debug", + "0.2.0+3 · 0123456 · 2026-09-16 · debug", "12.3 s after launch", "thread: main", "at: crates/benilla-app/src/net/apply.rs:100:5", diff --git a/crates/benilla-buildstamp/src/lib.rs b/crates/benilla-buildstamp/src/lib.rs index d313954d..a6bebeac 100644 --- a/crates/benilla-buildstamp/src/lib.rs +++ b/crates/benilla-buildstamp/src/lib.rs @@ -6,20 +6,23 @@ use std::path::Path; use std::process::Command; -/// Stamp the commit this binary was built from into the calling package as four `rustc-env` -/// vars, which its `main.rs` reads back with `env!` into a `BuildId`: +/// Stamp the commit this binary was built from into the calling package as five `rustc-env` +/// vars, which its `main.rs` reads back with `env!` into a `BuildId`, beside its own +/// `CARGO_PKG_VERSION`: /// /// - `BENILLA_GIT_SHA`: the full sha. /// - `BENILLA_GIT_SHORT`: git's own abbreviation of it. /// - `BENILLA_GIT_DATE`: the commit date (`%cs`, `YYYY-MM-DD`), not the build date. +/// - `BENILLA_GIT_DESCRIBE`: `git describe --tags --long` against the nearest release tag +/// (`v0.2.0-12-gb17be27`), which says how far past its release the commit is. /// - `BENILLA_PROFILE`: the profile directory's name, which tells `ship` from `release` where /// cargo's `PROFILE` does not. /// -/// The git vars are empty when git cannot answer (no `.git`, no `git` on `PATH`), which the -/// runtime reports as an unknown build. The rerun triggers are `HEAD` and the ref it names, -/// resolved with `git rev-parse --git-path` so a linked worktree resolves too; only paths that -/// exist are emitted, since cargo reruns a build script whose watched path is missing on every -/// build. +/// The git vars are empty when git cannot answer (no `.git`, no `git` on `PATH`, no tag in +/// reach), which the runtime reports as an unknown commit or the bare version. The rerun +/// triggers are `HEAD`, the ref it names, `packed-refs` and the tags, resolved with +/// `git rev-parse --git-path` so a linked worktree resolves too; only paths that exist are +/// emitted, since cargo reruns a build script whose watched path is missing on every build. pub fn emit() { let dir = std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"); let git = |args: &[&str]| -> Option { @@ -44,10 +47,12 @@ pub fn emit() { }; watch(git(&["rev-parse", "--git-path", "HEAD"])); if let Some(git_ref) = git(&["symbolic-ref", "-q", "HEAD"]) { - // The loose ref, plus `packed-refs`: a fresh clone's branch has no loose file. watch(git(&["rev-parse", "--git-path", &git_ref])); - watch(git(&["rev-parse", "--git-path", "packed-refs"])); } + // A fresh clone's refs live only in `packed-refs`; a fetched or new tag lands in either, and + // moves the describe without moving `HEAD`. Cargo scans a watched directory whole. + watch(git(&["rev-parse", "--git-path", "packed-refs"])); + watch(git(&["rev-parse", "--git-path", "refs/tags"])); // An edit to the rule restamps: cargo reruns a build script when `build.rs` or one of its // build-dependencies changes. println!("cargo::rerun-if-changed=build.rs"); @@ -56,6 +61,10 @@ pub fn emit() { ("BENILLA_GIT_SHA", &["rev-parse", "HEAD"][..]), ("BENILLA_GIT_SHORT", &["rev-parse", "--short", "HEAD"]), ("BENILLA_GIT_DATE", &["log", "-1", "--format=%cs"]), + ( + "BENILLA_GIT_DESCRIBE", + &["describe", "--tags", "--long", "--match", "v[0-9]*"], + ), ] { println!( "cargo::rustc-env={var}={}", diff --git a/crates/benilla-world/src/build_id.rs b/crates/benilla-world/src/build_id.rs index 1773634f..03857dd4 100644 --- a/crates/benilla-world/src/build_id.rs +++ b/crates/benilla-world/src/build_id.rs @@ -1,13 +1,19 @@ -//! Which build is running: the commit the binary was built from, stamped at compile time by the -//! launcher shims' build script (`benilla-buildstamp`) and passed to `run` as the [`BuildId`] -//! resource. It shows in the startup log line ([`banner`]) and in the debug panel's footer. +//! Which build is running: the release and the commit the binary was built from, stamped at +//! compile time by the launcher shims' build script (`benilla-buildstamp`) and passed to `run` as +//! the [`BuildId`] resource. It shows in the startup log line ([`banner`]), in crash reports and in +//! the debug panel's footer. use bevy::prelude::*; -/// The launcher shim's compile-time git stamp. The git fields are empty when the build had no -/// checkout or no `git` on `PATH`, which [`BuildId::summary`] reports as an unknown build. +/// The launcher shim's compile-time stamp. The git fields are empty when the build had no +/// checkout or no `git` on `PATH`, which [`BuildId::summary`] reports as an unknown commit. #[derive(Resource, Clone, Copy)] pub struct BuildId { + /// The workspace version from `Cargo.toml`, which every release bumps. + pub version: &'static str, + /// `git describe --tags --long` against the nearest `v*` tag (`v0.2.0-12-gb17be27`); empty + /// when no release tag is in reach, as in a source archive or a shallow clone. + pub describe: &'static str, /// The full sha, which the debug panel copies. pub sha: &'static str, /// Git's own abbreviation of [`sha`](Self::sha). @@ -19,17 +25,106 @@ pub struct BuildId { } impl BuildId { - /// The one-line build id: short sha, commit date and profile. - pub fn summary(&self) -> String { - if self.short.is_empty() { - format!("unknown (built without a git checkout) · {}", self.profile) - } else { - format!("{} · {} · {}", self.short, self.date, self.profile) + /// The release this build is: the version, and `+N` when the commit is N past that version's + /// tag. With no tag for this version in reach (none fetched, or the release commit before its + /// tag exists), the version alone. + pub fn release(&self) -> String { + match commits_past_tag(self.describe, self.version) { + Some(n) if n > 0 => format!("{}+{n}", self.version), + _ => self.version.to_owned(), } } + + /// The one-line build id: release, short sha, commit date and profile. + pub fn summary(&self) -> String { + if self.short.is_empty() { + format!( + "{} · unknown commit (built without a git checkout) · {}", + self.release(), + self.profile + ) + } else { + format!( + "{} · {} · {} · {}", + self.release(), + self.short, + self.date, + self.profile + ) + } + } +} + +/// How many commits past `v{version}` a `--long` describe (`--g`) is, or `None` +/// when the describe is empty, malformed or names another version's tag. +fn commits_past_tag(describe: &str, version: &str) -> Option { + let mut parts = describe.rsplitn(3, '-'); + parts.next().filter(|hash| hash.starts_with('g'))?; + let n: u32 = parts.next()?.parse().ok()?; + let tag = parts.next()?; + (tag.strip_prefix('v')? == version).then_some(n) } /// Log the build id once at startup, not env-gated, so every pasted log carries it. pub fn banner(build: Res) { info!("benilla build {}", build.summary()); } + +#[cfg(test)] +mod tests { + use super::*; + + fn build(version: &'static str, describe: &'static str, short: &'static str) -> BuildId { + BuildId { + version, + describe, + sha: "", + short, + date: "2026-09-27", + profile: "release", + } + } + + #[test] + fn a_tagged_release_reads_as_its_version() { + let b = build("0.2.0", "v0.2.0-0-gb17be27", "b17be27"); + assert_eq!(b.summary(), "0.2.0 · b17be27 · 2026-09-27 · release"); + } + + #[test] + fn a_commit_past_the_release_counts_its_distance() { + assert_eq!( + build("0.2.0", "v0.2.0-12-gb17be27", "b17be27").release(), + "0.2.0+12" + ); + // A pre-release tag's own hyphens stay in the tag. + assert_eq!( + build("1.0.0-rc1", "v1.0.0-rc1-3-gabc1234", "abc1234").release(), + "1.0.0-rc1+3" + ); + } + + #[test] + fn another_versions_tag_or_none_reads_as_the_version_alone() { + // The release commit itself, before its tag exists: the nearest tag is the previous one. + assert_eq!( + build("0.2.0", "v0.1.0-450-gb17be27", "b17be27").release(), + "0.2.0" + ); + // No release tag in reach: a source archive or a shallow clone. + assert_eq!(build("0.2.0", "", "b17be27").release(), "0.2.0"); + assert_eq!( + build("0.2.0", "not-a-describe", "b17be27").release(), + "0.2.0" + ); + } + + #[test] + fn a_build_without_git_still_names_its_release() { + let b = build("0.2.0", "", ""); + assert_eq!( + b.summary(), + "0.2.0 · unknown commit (built without a git checkout) · release" + ); + } +} diff --git a/crates/benilla-worldview/src/main.rs b/crates/benilla-worldview/src/main.rs index f99659ba..07ef880b 100644 --- a/crates/benilla-worldview/src/main.rs +++ b/crates/benilla-worldview/src/main.rs @@ -6,6 +6,8 @@ use benilla_world::build_id::BuildId; fn main() -> benilla_world::AppExit { benilla_world::worldview::run(BuildId { + version: env!("CARGO_PKG_VERSION"), + describe: env!("BENILLA_GIT_DESCRIBE"), sha: env!("BENILLA_GIT_SHA"), short: env!("BENILLA_GIT_SHORT"), date: env!("BENILLA_GIT_DATE"), diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index b14bc7ea..c9631222 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -5,6 +5,8 @@ use benilla_app::BuildId; fn main() -> benilla_app::AppExit { benilla_app::run(BuildId { + version: env!("CARGO_PKG_VERSION"), + describe: env!("BENILLA_GIT_DESCRIBE"), sha: env!("BENILLA_GIT_SHA"), short: env!("BENILLA_GIT_SHORT"), date: env!("BENILLA_GIT_DATE"), From 519602308b5aa50fb1ec0cc51de247086ec8a521 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:38:57 +0700 Subject: [PATCH 03/11] contributing: an issue labelled `in progress` is taken, and a draft pull request marks one you start (#454) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/CONTRIBUTING.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7dc3c497..128e5e6b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -9,8 +9,10 @@ fork, and forks are welcome. The [issues](https://github.com/samwhosung/benilla/issues) the maintainers file are checked against 1.12.1 first: each says what 1.12.1 does, what benilla does instead, and where in the -code, and any of them is a place to start. Before you start one, look for an open pull request -that already covers it, and say `Fixes #N` in yours. +code, and any of them is a place to start. One labelled `in progress` is taken: someone is +working on it or a pull request is open for it, so pick another. Once you start one, open a draft +pull request that says `Fixes #N` as soon as you have a first commit, so the issue shows it is +taken. ## What gets in From 0106b66b25176413ec66fa98f40acb6a65fa89e9 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:13:15 +0700 Subject: [PATCH 04/11] A unit's reaction caps the reputation rank at Revered, as in 1.12.1, so an Exalted faction's NPC no longer breaks the target frame (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeting an NPC whose faction you are Exalted with no longer opens a script error. `UnitReaction` answered 8 there, and stock `TargetFrame_CheckFaction` indexes `UnitReactionColor[reaction]`, which has seven entries (`TargetFrame.lua:6-14`, `:183`). - **The cap.** `UnitReaction 0x6061e0` takes `0x606530`'s answer through `min(rank, 6)` at `0x606439` (`cmp esi,6` / `jl` / `mov esi,6`), so Revered and Exalted both read 6, and the Lua binding adds one (`0x51683e`), so Lua sees at most 7. `ring_reaction` now caps its answer there. - **The ladder stays whole.** `reputation_rank` is untouched: the reputation pane and the item requirement lines still read Exalted. - **Every caller** of `ring_reaction` models `0x6061e0` and tests at 1, 3, 4 or 5, so none changes but the four Lua unit feeds (target, targettarget, npc, mouseover), which now share one `unit_reaction` helper. The vendor discount's Exalted row reads as Revered, as in the reference, and still takes 10%. - **Comments** that gave the scale as 0..7 or 1..8 now say 0..=6 and 1..=7. Checked: `the_reputation_rank_is_capped_at_revered` walks both sides of the Honored, Revered and Exalted thresholds; `an_exalted_npc_target_reads_revered_and_its_plate_is_green` runs stock `TargetFrame` on an Exalted Stormwind unit. With the cap loosened to 7 both fail, the second with the issue's `TargetFrame.lua:183: attempt to index field '?'`; restored, both pass. Gates green on macOS; no platform seam touched. Fixes #440 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/debug_panel/inspect.rs | 4 +- .../benilla-app/src/target/price_discount.rs | 5 +- crates/benilla-app/src/target/ring.rs | 67 ++++++++++++-- crates/benilla-app/src/ui_reputation.rs | 2 +- .../src/ui_script/unit_frame_tests.rs | 89 ++++++++++++++++--- crates/benilla-app/src/ui_tooltip/mod.rs | 13 +-- crates/benilla-app/src/ui_unit.rs | 33 ++++--- crates/benilla-formats/src/factions.rs | 8 +- crates/benilla-ui/src/script/unit/mod.rs | 5 +- 9 files changed, 171 insertions(+), 55 deletions(-) diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index 5cd74b11..e49bea0b 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -443,8 +443,8 @@ pub(super) fn inspect_ui( 3 => "neutral", 4 => "friendly", 5 => "honored", - 6 => "revered", - _ => "exalted", + // `0x606439` caps the rank at 6: Exalted reads here too. + _ => "revered+", }; // A faction with a reputation slot reacts by our standing (`0x605fc0` -> `0x4d63a0`), // before any template comparison; everything else by the template comparator. diff --git a/crates/benilla-app/src/target/price_discount.rs b/crates/benilla-app/src/target/price_discount.rs index 9f72b956..0ae1f763 100644 --- a/crates/benilla-app/src/target/price_discount.rs +++ b/crates/benilla-app/src/target/price_discount.rs @@ -119,8 +119,9 @@ mod tests { (5, UNIT_FLAG_PVP, 7, D15), (5, UNIT_FLAG_PVP, 8, D20), (5, UNIT_FLAG_PVP, 19, D20), - // Revered and up pass the same signed `cmp eax,5`; Neutral does not. - (7, UNIT_FLAG_PVP, 19, D20), + // Revered, which Exalted reads too (`0x606439`), passes the same signed `cmp eax,5`; + // Neutral does not. + (6, UNIT_FLAG_PVP, 19, D20), (3, UNIT_FLAG_PVP, 19, D10), // Every other flag bit set but the PvP one: no honor step. (5, !UNIT_FLAG_PVP, 19, D10), diff --git a/crates/benilla-app/src/target/ring.rs b/crates/benilla-app/src/target/ring.rs index 2780d2a2..1bbebd10 100644 --- a/crates/benilla-app/src/target/ring.rs +++ b/crates/benilla-app/src/target/ring.rs @@ -118,11 +118,12 @@ impl RingVariant { } } -/// The selector's branch (`0x605960`) on the raw reaction rank `0..=7`. A player never grays: rank -/// 0-1 reads red, standing in for the reference's attackability matrix, else green when PvP-flagged -/// and blue when not, paler for a party member; self is not in the party table. An NPC reads gray -/// when dead, else the rank palette. The reference takes the player branch on `UNIT_FIELD_FLAGS` -/// bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object type. +/// The selector's branch (`0x605960`) on [`ring_reaction`]'s rank `0..=6`. A player never grays: +/// rank 0-1 reads red, standing in for the reference's attackability matrix, else green when +/// PvP-flagged and blue when not, paler for a party member; self is not in the party table. An NPC +/// reads gray when dead, else the rank palette. The reference takes the player branch on +/// `UNIT_FIELD_FLAGS` bit 3 (`0x605baa`), which a player's pet carries too; callers pass the object +/// type. pub(crate) fn ring_variant( rank: u8, is_player: bool, @@ -400,13 +401,14 @@ pub(super) fn push_ring( batch.tris(); } -/// The target's reaction toward our player as a raw rank `0..=7`, the direction every NPC colour +/// The target's reaction toward our player as a rank `0..=6`, the direction every NPC colour /// uses (`0x605960` and `0x7cbaa0` ask `UnitReaction 0x6061e0` with the unit as `this`). When /// both carry `UNIT_FIELD_FLAGS` bit 3, a duel (`0x606296`) or mutual FFA decides first. Then /// `0x606530`: a faction with a reputation slot (`0x605fc0`) answers with our reputation rank /// (`0x4d63a0`), even in GM mode; any other goes to the template comparator (`0x606640`). Neutral -/// when anything is missing. Not applied: the party rung (`0x6062b0`), the contested guard, -/// forced reactions and the summon tail. +/// when anything is missing. `0x606439` caps that answer at 6, so Revered and Exalted both read 6. +/// Not applied: the party rung (`0x6062b0`), the contested guard, forced reactions and the summon +/// tail. pub(crate) fn ring_reaction( factions: Option<&Factions>, reputations: &Reputations, @@ -439,9 +441,13 @@ pub(crate) fn ring_reaction( let self_tpl = catalog.template(self_store.0.unit_faction_template()?)?; Some(target_tpl.reaction_toward(self_tpl) as u8) })(); - resolved.unwrap_or(Reaction::Neutral as u8) + // `0x606439` (`cmp esi,6` / `jl` / `mov esi,6`) caps `0x606530`'s answer: Exalted's 7 reads 6. + resolved.map_or(Reaction::Neutral as u8, |rank| rank.min(REACTION_CAP)) } +/// Revered, the highest rank `UnitReaction`'s generic leg answers (`0x606439`). +const REACTION_CAP: u8 = 6; + /// `UNIT_FIELD_FLAGS` bit 3, player-controlled in behaviour: players and their pets carry it, wild /// creatures do not. `UnitReaction` needs it on both sides before any player-vs-player rung /// (`0x606217`, `0x60622f`), and `CanAttack` picks its reaction arm by it (`0x606a13`). @@ -858,6 +864,49 @@ mod tests { assert_eq!(rank(&unit(12), &quiet), 4); } + /// The reputation leg walks the ladder up to Revered and stops: `0x606439` caps the rank at 6, + /// so a Stormwind unit reads the same to a Revered and an Exalted human, while the ladder + /// itself, which the reputation pane reads, still reaches 7. + #[test] + fn the_reputation_rank_is_capped_at_revered() { + use crate::net::ObjectStore; + use crate::target::{stormwind_fixture, HUMAN_WARRIOR}; + use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE; + use benilla_protocol::ObjectFields; + + /// `UNIT_FIELD_BYTES_0`, absolute descriptor index. + const BYTES_0: u16 = 36; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)])); + // Both sides of the Honored, Revered and Exalted thresholds, and the wire's own ceiling. + for (total, want) in [ + (8_999, 4), + (9_000, 5), + (20_999, 5), + (21_000, 6), + (41_999, 6), + (42_000, 6), + (42_999, 6), + ] { + let (factions, template, reps) = stormwind_fixture(&mut chain, total); + let unit = ObjectStore(ObjectFields::from_pairs(&[( + FIELD_UNIT_FACTIONTEMPLATE, + template, + )])); + assert_eq!( + ring_reaction(Some(&factions), &reps, Some(&unit), Some(&me)), + want, + "standing {total}" + ); + } + assert_eq!( + benilla_formats::reputation_rank(42_000), + 7, + "the ladder is not capped" + ); + } + /// Cenarion Circle, faction 609 with reputation slot 36: not at war, its NPCs are neither /// attackable nor, without a service bit, interactable, while the rank stays neutral. #[test] diff --git a/crates/benilla-app/src/ui_reputation.rs b/crates/benilla-app/src/ui_reputation.rs index a24612fa..f152d286 100644 --- a/crates/benilla-app/src/ui_reputation.rs +++ b/crates/benilla-app/src/ui_reputation.rs @@ -10,7 +10,7 @@ //! The wire standing excludes the race/class base, so the pane's total is base plus wire: vmangos //! stores `standing - BaseRep` (`ReputationMgr.cpp:261`) and reports the sum //! (`ReputationMgr.cpp:82`). The rank is [`benilla_formats::reputation_rank`], shared with the -//! unit-reaction decode so the pane and the nameplate agree. +//! unit-reaction decode, which caps it at 6, Revered (`0x606439`); the pane shows it uncapped. use bevy::prelude::*; diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 40cf8ccd..35adaa79 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -834,6 +834,22 @@ fn pvp_icon_follows_the_three_branch_law() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// The target frame's name plate tint, `TargetFrameNameBackground`'s vertex colour as drawn. +fn plate_color(s: &mut UiScript) -> [f32; 4] { + s.resolve(); + s.extract() + .into_iter() + .find_map(|q| match q.content { + QuadContent::Texture { + path: Some(p), + color: Some(c), + .. + } if p.contains("LevelBackground") => Some(c), + _ => None, + }) + .expect("target name-plate quad present") +} + /// A friendly player's plate is green when PvP-flagged, else blue (`TargetFrame.lua:163-172`). #[test] fn flagged_friendly_player_plate_is_green() { @@ -842,20 +858,6 @@ fn flagged_friendly_player_plate_is_green() { s.set_screen_size(1024.0, 768.0); load_unit_frames(&s); - let plate_color = |s: &mut UiScript| -> [f32; 4] { - s.resolve(); - s.extract() - .into_iter() - .find_map(|q| match q.content { - QuadContent::Texture { - path: Some(p), - color: Some(c), - .. - } if p.contains("LevelBackground") => Some(c), - _ => None, - }) - .expect("target name-plate quad present") - }; let friendly_player = |pvp: bool| UnitState { exists: true, name: Some("Guildmate".into()), @@ -889,6 +891,65 @@ fn flagged_friendly_player_plate_is_green() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// An NPC of a faction we are Exalted with reads as Revered: `0x606439` caps the rank at 6 and the +/// binding adds one (`0x51683e`), so `UnitReaction` answers 7 and stock `TargetFrame_CheckFaction` +/// finds the last of `UnitReactionColor`'s seven entries (`TargetFrame.lua:6-14`, `:183`). +#[test] +fn an_exalted_npc_target_reads_revered_and_its_plate_is_green() { + use crate::net::ObjectStore; + use crate::target::{stormwind_fixture, HUMAN_WARRIOR}; + use benilla_protocol::field::FIELD_UNIT_FACTIONTEMPLATE; + use benilla_protocol::ObjectFields; + + /// `UNIT_FIELD_BYTES_0`, absolute descriptor index. + const BYTES_0: u16 = 36; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_unit_frames(&s); + let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)])); + + for total in [21_000, 42_000] { + let (factions, template, reps) = stormwind_fixture(&mut chain, total); + let guard = ObjectStore(ObjectFields::from_pairs(&[( + FIELD_UNIT_FACTIONTEMPLATE, + template, + )])); + let reaction = crate::ui_unit::unit_reaction(Some(&factions), &reps, &guard, Some(&me)); + s.set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Stormwind Guard".into()), + health: 100, + max_health: 100, + level: 55, + reaction, + is_connected: true, + ..UnitState::default() + }), + ); + s.fire_event("PLAYER_TARGET_CHANGED", vec![]); + assert!( + s.errors().is_empty(), + "standing {total}: script errors: {:?}", + s.errors() + ); + assert_eq!( + s.eval::(r#"return UnitReaction("target", "player")"#) + .unwrap(), + 7, + "standing {total}" + ); + let green = plate_color(&mut s); + assert!( + green[0].abs() < 1e-6 && (green[1] - 1.0).abs() < 1e-6 && green[2].abs() < 1e-6, + "standing {total}: the plate is UnitReactionColor[7], green, got {green:?}" + ); + } +} + /// Stock `TargetFrame_CheckClassification` (`TargetFrame.lua:205-218`), asserted on the drawn /// quads: elite, rare-elite and world boss share the Elite art (1.12 ships no rare-elite border), /// and `UNIT_CLASSIFICATION_CHANGED` alone repaints it. diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 34b741fc..f6122ccf 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -12,13 +12,11 @@ use benilla_ui::strings::Arg; use crate::items::Items; use crate::names::NameCache; use crate::net::{NetCommands, ObjectStore, Objects, SelfPlayer}; -use crate::target::{ - go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC, -}; +use crate::target::{go_is_nearest, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC}; use crate::ui_action::{PlayerActions, Spells}; use crate::ui_script::UiFeed; use crate::ui_trainer::{TrainerFeed, TrainerTooltipSubjects}; -use crate::ui_unit::{enrich_unit, snapshot, UnitFeed}; +use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, UnitFeed}; pub struct UiTooltipPlugin; @@ -629,12 +627,7 @@ fn drive_mouseover_tooltip( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - let reaction = ring_reaction( - rx.factions.as_deref(), - &rx.reputations, - Some(store), - self_store, - ) + 1; + let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store); let mut s = snapshot(store, name, reaction, chr); enrich_unit( &mut s, diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 5132dd48..fa844a85 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -743,8 +743,20 @@ pub(crate) struct UnitStores<'w, 's> { edges: MessageReader<'w, 's, FieldChanged>, } +/// `UnitReaction(unit, "player")` (`0x5167e0`): [`ring_reaction`] plus one (`0x51683e`), so +/// `1..=7`, Hated to Revered, and Exalted reads 7 too (`0x606439`). Stock `UnitReactionColor` +/// has seven entries (`TargetFrame.lua:6-14`), one per value. +pub(crate) fn unit_reaction( + factions: Option<&Factions>, + reputations: &Reputations, + store: &ObjectStore, + self_store: Option<&ObjectStore>, +) -> u8 { + ring_reaction(factions, reputations, Some(store), self_store) + 1 +} + /// Build a unit snapshot from a streamed descriptor, its cached name and its `UnitReaction` -/// (`1..8`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column. +/// (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column. pub(crate) fn snapshot( store: &ObjectStore, name: Option, @@ -1193,13 +1205,12 @@ fn feed_units( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - // `UnitReaction` (1..8) is the selection ring's 0..7 rank plus one. - let reaction = ring_reaction( + let reaction = unit_reaction( factions.as_deref(), &reputations, - Some(store), + store, self_pair.map(|(s, _)| s), - ) + 1; + ); let mut s = snapshot(store, name, reaction, chr); s.guid = guid; s.is_connected = true; @@ -1238,12 +1249,12 @@ fn feed_units( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - let reaction = ring_reaction( + let reaction = unit_reaction( factions.as_deref(), &reputations, - Some(store), + store, self_pair.map(|(s, _)| s), - ) + 1; + ); let mut s = snapshot(store, name, reaction, chr); s.guid = guid; s.is_connected = true; @@ -1306,12 +1317,12 @@ fn feed_units( let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); - let reaction = ring_reaction( + let reaction = unit_reaction( factions.as_deref(), &reputations, - Some(store), + store, self_pair.map(|(s, _)| s), - ) + 1; + ); let mut s = snapshot(store, name, reaction, chr); s.guid = guid; s.is_connected = true; diff --git a/crates/benilla-formats/src/factions.rs b/crates/benilla-formats/src/factions.rs index 4bf41851..ef8f18eb 100644 --- a/crates/benilla-formats/src/factions.rs +++ b/crates/benilla-formats/src/factions.rs @@ -36,7 +36,7 @@ pub struct FactionTemplate { } /// A unit's base reaction toward another on the reference's scale: `0x606640` returns these three, -/// and the reputation path widens the scale to 0..7, hence the gaps. +/// and the reputation path widens the scale to 0..=6, hence the gaps. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Reaction { Hostile = 1, @@ -154,9 +154,9 @@ impl FactionInfo { } } -/// Total standing (base + wire) to rank 0..=7, hated to exalted, which is also the reference's -/// extended reaction scale. Widths from −42000: 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000 -/// (vmangos `PointsInRank`). +/// Total standing (base + wire) to rank 0..=7, hated to exalted, the reference's ladder +/// (`0x4d63a0`); the unit reaction takes it capped at 6, Revered (`0x606439`). Widths from −42000: +/// 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000 (vmangos `PointsInRank`). pub fn reputation_rank(total_standing: i32) -> u8 { match total_standing { i32::MIN..=-6001 => 0, // hated diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index b141be55..d6989874 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -91,8 +91,9 @@ pub struct UnitState { pub charmed: bool, /// `UnitIsGhost`: `PLAYER_FLAGS` bit `0x10`, so players only. pub ghost: bool, - /// The reaction toward the player on `UnitReaction`'s scale, 1 hated to 8 exalted, 0 unknown - /// (nil). Fed for the tokens naming another unit; `"player"` and `"pet"` stay 0. + /// The reaction toward the player on `UnitReaction`'s scale, 1 hated to 7 revered, which + /// Exalted reads too (`0x606439` caps the rank), 0 unknown (nil). Fed for the tokens naming + /// another unit; `"player"` and `"pet"` stay 0. pub reaction: u8, /// `UnitRace`'s localized first return, from `UNIT_FIELD_BYTES_0` byte 0; `None` answers /// `nil, nil`. From 243fd09154374f2f3058f7d58f5ba59eb62846be Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:43:33 +0700 Subject: [PATCH 05/11] Logging in to an account with no characters lands on the empty character list, as in 1.12.1, instead of creating a Human Warrior (#457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in to an account or realm with no characters lands on the empty character list. Until now the login cycle created a male Human Warrior on the server, named `WOW_CHAR` or "One", on every login that found an empty roster, a typed one included and again after Change Realm. - **The reference.** The client builds `CMSG_CHAR_CREATE` in one place, `0x5aac50`, reached only from the glue binding `CreateCharacter` (`0x4719e0` → `0x4711d0` → `0x46b290` → `0x5aac50`), which stock `CharacterCreate.lua` calls only from `CharacterCreate_Okay`. With an empty roster, stock `CharacterSelect.lua`'s `UpdateCharacterList` disables Enter World and Delete and shows Create New Character, which benilla's select screen already does. - **The change.** The create-if-empty step is gone from the per-realm cycle, and `NetConfig`, which existed only for it, goes with it. The create screen's own path is untouched. - **Scripted runs.** `WOW_CHAR` must now be on the account: a driverless run whose `WOW_CHAR` is missing fails at select, as it already did on a non-empty roster, and a player's run shows the list. `docs/CONTRIBUTING.md` says so, and names `WOW_PROBE_CHARCREATE` as the explicit way to make one. Checked: `an_empty_account_parks_at_select_without_creating_a_character` drives the real login cycle against a fake realmd (the server half of SRP6) and a fake world server answering an empty roster. It fails with the old step restored (the client sends `CMSG_CHAR_CREATE` on its own) and passes without it. Live against a local vmangos on a fresh account with no characters: before, the server gained a level-1 "One"; after, the client parks on the empty list and the account still holds no character. With a `WOW_CHAR` not on the account, a driverless run exits at select, and nothing is created. Gates and the smoke run green on macOS; no platform seam touched. Fixes #343 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 3 + crates/benilla-app/Cargo.toml | 6 + crates/benilla-app/src/net.rs | 2 +- crates/benilla-app/src/net/io.rs | 53 +--- crates/benilla-app/src/net/io/cycle_tests.rs | 274 +++++++++++++++++++ docs/CONTRIBUTING.md | 6 +- docs/MAP.md | 2 +- 7 files changed, 298 insertions(+), 48 deletions(-) create mode 100644 crates/benilla-app/src/net/io/cycle_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ab0e83e3..8e8e613f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "benilla-assets", "benilla-formats", "benilla-protocol", + "benilla-srp", "benilla-ui", "benilla-world", "bevy", @@ -528,6 +529,7 @@ dependencies = [ "kira", "libc", "mint", + "num-bigint", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-core-audio", @@ -538,6 +540,7 @@ dependencies = [ "regex", "rtrb", "serde", + "sha1", "smithay-clipboard", "symphonia", "toml", diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index c6b65cd4..80ed5285 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -96,3 +96,9 @@ cpal = "0.17" # Windows CPU clocks for `perf::clock`. [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + +# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`). +[dev-dependencies] +benilla-srp = { path = "../benilla-srp" } +num-bigint = "0.4.6" +sha1.workspace = true diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index e4be9117..639cb436 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -66,7 +66,7 @@ impl Plugin for NetPlugin { crate::query_cache::register::(app); crate::query_cache::register::(app); crate::query_cache::register::(app); - let handles = io::spawn_net(io::NetConfig::from_env(), self.connect); + let handles = io::spawn_net(self.connect); if !self.connect { app.insert_resource(NetOffline); } diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index b34539c3..d9245ce8 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, Result}; use benilla_assets::LockRecover; use benilla_protocol::{ host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent, @@ -145,21 +145,6 @@ impl PingClock { /// Cap on send warnings per connection, so a movement stream during an outage cannot flood the log. const SEND_WARN_CAP: u32 = 8; -/// The per-process connection parameters. Credentials and address ride each [`LoginRequest`]; -/// `$WOW_HOST` is read by `Realmlist::default()`. -pub(super) struct NetConfig { - /// `WOW_CHAR`: here only the name of the starter character on an empty account. - character: Option, -} - -impl NetConfig { - pub(super) fn from_env() -> Self { - NetConfig { - character: std::env::var("WOW_CHAR").ok(), - } - } -} - /// What one wake-up at the character park asked for, so every jump out is made outside `select!`. enum Parked { /// `CMSG_PLAYER_LOGIN` with this guid. @@ -204,7 +189,7 @@ pub(super) struct NetHandles { } /// Spawns the read thread with its park and cycle loop, and the one long-lived write thread. -pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { +pub(super) fn spawn_net(connect: bool) -> NetHandles { let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let (pick_tx, pick_rx) = crossbeam_channel::unbounded::(); @@ -247,7 +232,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { // writer, since the keepalive can still fire on the stale one until then. read_clock.lock_recover().clear(); match run( - &cfg, &events_tx, &writer_tx, &parks, @@ -310,7 +294,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles { /// dies, the character logs out or the app exits. A pre-roster failure emits /// [`SessionEvent::LoginFailed`] and re-parks; any retry is the app's. fn run( - cfg: &NetConfig, events_tx: &Sender, writer_tx: &Sender, parks: &Parks, @@ -501,31 +484,10 @@ fn run( return Ok(Cycle::Repark); } - // The roster, creating a starter character on an empty account so `PLAYER_LOGIN` has a - // target. Failures here are still pre-roster. - let mut characters = match (|| -> Result> { - let mut characters = session.char_enum()?; - if characters.is_empty() { - let name = cfg.character.as_deref().unwrap_or("One"); - let starter = messages::CharCreateReq { - name: name.to_string(), - race: messages::RACE_HUMAN, - class: messages::CLASS_WARRIOR, - gender: messages::GENDER_MALE, - skin: 0, - face: 0, - hair_style: 0, - hair_color: 0, - facial_hair: 0, - }; - match session.create_character(&starter)? { - messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {} - other => bail!("character creation failed: result {other:#x}"), - } - characters = session.char_enum()?; - } - Ok(characters) - })() { + // The roster as the account holds it, an empty one included: the reference builds + // `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the create screen. + // Failures here are still pre-roster. + let mut characters = match session.char_enum() { Ok(c) => c, Err(e) => { if canceled() { @@ -1530,6 +1492,9 @@ fn writer_loop( } } +#[cfg(test)] +mod cycle_tests; + #[cfg(test)] mod rtt_tests { use super::{PingClock, RTT_RING}; diff --git a/crates/benilla-app/src/net/io/cycle_tests.rs b/crates/benilla-app/src/net/io/cycle_tests.rs new file mode 100644 index 00000000..c30a155f --- /dev/null +++ b/crates/benilla-app/src/net/io/cycle_tests.rs @@ -0,0 +1,274 @@ +//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback: the +//! real logon, realm park, world handshake and roster, driven through the parks as the app does. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use benilla_protocol::messages::{self, opcode}; +use benilla_protocol::{SessionEvent, WorldWriter}; +use benilla_srp::vanilla_header::HeaderCrypto; +use benilla_srp::{ + password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN, + SESSION_KEY_LENGTH, +}; +use crossbeam_channel::{Receiver, Sender}; +use num_bigint::BigUint; +use sha1::{Digest, Sha1}; + +use super::{run, Cycle, LoginRequest, Parks, PingClock}; +use crate::net::{CharRequest, RealmRequest}; + +const USER: &str = "empty"; +const PASS: &str = "secret"; +const REALM: &str = "Fake"; +const SALT: [u8; 32] = [0x5a; 32]; +/// Every wait in the test: long enough for a loaded machine, short of a hung gate. +const WAIT: Duration = Duration::from_secs(20); + +fn sha1(parts: &[&[u8]]) -> [u8; 20] { + let mut h = Sha1::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +fn le32(v: &BigUint) -> [u8; 32] { + let raw = v.to_bytes_le(); + let mut out = [0u8; 32]; + out[..raw.len()].copy_from_slice(&raw); + out +} + +/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does. +fn interleave(s: &[u8; 32]) -> [u8; SESSION_KEY_LENGTH] { + let even: Vec = s.iter().step_by(2).copied().collect(); + let odd: Vec = s.iter().skip(1).step_by(2).copied().collect(); + let (g, h) = (sha1(&[&even]), sha1(&[&odd])); + let mut out = [0u8; SESSION_KEY_LENGTH]; + for (i, (gi, hi)) in g.iter().zip(&h).enumerate() { + out[i * 2] = *gi; + out[i * 2 + 1] = *hi; + } + out +} + +/// A realmd that logs [`USER`] in with the server half of SRP6 and lists one realm, [`REALM`] at +/// `world`. The session key goes to the fake world server, which needs it for the header cipher. +fn fake_realmd(world: String, keys: Sender<[u8; SESSION_KEY_LENGTH]>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + let g = BigUint::from(GENERATOR); + let user = NormalizedString::new(USER).unwrap(); + let pass = NormalizedString::new(PASS).unwrap(); + let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT)); + + // CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body. + let mut head = [0u8; 4]; + s.read_exact(&mut head).unwrap(); + let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))]; + s.read_exact(&mut body).unwrap(); + + // B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client + // redials on a zero one. + let (b, b_pub) = (1u32..) + .map(|i| { + let b = BigUint::from_bytes_le(&[0x3c; 32]) + i; + let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n)); + (b, b_pub) + }) + .find(|(_, b_pub)| b_pub[31] != 0) + .unwrap(); + let mut reply = vec![0x00, 0x00, 0x00]; + reply.extend_from_slice(&b_pub); + reply.extend_from_slice(&[1, GENERATOR, 32]); + reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + reply.extend_from_slice(&SALT); + reply.extend_from_slice(&[0u8; 16]); // crc_salt + reply.push(0); // security_flag + s.write_all(&reply).unwrap(); + + // CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag. + let mut proof = [0u8; 75]; + s.read_exact(&mut proof).unwrap(); + let (a_pub, m1) = (&proof[1..33], &proof[33..53]); + let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub])); + let a = BigUint::from_bytes_le(a_pub); + let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n); + let key = interleave(&le32(&big_s)); + let mut reply = vec![0x01, 0x00]; + reply.extend_from_slice(&sha1(&[a_pub, m1, &key])); + reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id + s.write_all(&reply).unwrap(); + keys.send(key).unwrap(); + + // CMD_REALM_LIST_Client, answered with the one realm. + s.read_exact(&mut [0u8; 5]).unwrap(); + let mut realm = Vec::new(); + realm.extend_from_slice(&0u32.to_le_bytes()); // realm_type + realm.push(0); // flags + realm.extend_from_slice(format!("{REALM}\0{world}\0").as_bytes()); + realm.extend_from_slice(&0f32.to_le_bytes()); // population + realm.extend_from_slice(&[0, 1, 1]); // characters, category, id + let mut list = vec![0x10]; + list.extend_from_slice(&((4 + 1 + realm.len() + 2) as u16).to_le_bytes()); + list.extend_from_slice(&0u32.to_le_bytes()); + list.push(1); + list.extend_from_slice(&realm); + list.extend_from_slice(&0u16.to_le_bytes()); + s.write_all(&list).unwrap(); + + // Held until the cycle drops the logon, as realmd keeps it for the list refresh. + let _ = s.read(&mut [0u8; 1]); + }); + addr +} + +/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body. +fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) { + let size = ((body.len() + 2) as u16).to_be_bytes(); + let op = op.to_le_bytes(); + let mut header = [size[0], size[1], op[0], op[1]]; + if let Some(c) = crypto { + c.encrypter().encrypt(&mut header); + } + s.write_all(&header).unwrap(); + s.write_all(body).unwrap(); +} + +/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode. +fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option { + let mut header = [0u8; 6]; + s.read_exact(&mut header).ok()?; + if let Some(c) = crypto { + c.decrypter().decrypt(&mut header); + } + let size = usize::from(u16::from_be_bytes([header[0], header[1]])); + s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?; + Some(u16::from_le_bytes([header[2], header[3]])) +} + +/// A world server whose account has no characters: it admits the session under realmd's key, +/// answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with success, and +/// logs every opcode the client sends. +fn fake_world( + listener: TcpListener, + keys: Receiver<[u8; SESSION_KEY_LENGTH]>, +) -> Arc>> { + let sent = Arc::new(Mutex::new(Vec::new())); + let log = Arc::clone(&sent); + thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + send( + &mut s, + None, + opcode::SMSG_AUTH_CHALLENGE, + &0xDEAD_BEEFu32.to_le_bytes(), + ); + assert_eq!(recv(&mut s, None), Some(opcode::CMSG_AUTH_SESSION)); + let mut crypto = HeaderCrypto::from_session_key(keys.recv_timeout(WAIT).unwrap()); + send( + &mut s, + Some(&mut crypto), + opcode::SMSG_AUTH_RESPONSE, + &[messages::AUTH_OK], + ); + while let Some(op) = recv(&mut s, Some(&mut crypto)) { + log.lock().unwrap().push(op); + match op { + opcode::CMSG_CHAR_ENUM => { + send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]); + } + opcode::CMSG_CHAR_CREATE => send( + &mut s, + Some(&mut crypto), + opcode::SMSG_CHAR_CREATE, + &[messages::CHAR_CREATE_SUCCESS], + ), + _ => {} + } + } + }); + sent +} + +/// The next event, skipping the stage announcements; a login failure fails the test by name. +fn next_event(events: &Receiver) -> SessionEvent { + loop { + match events.recv_timeout(WAIT).expect("the cycle went quiet") { + SessionEvent::LoginStage { .. } => continue, + SessionEvent::LoginFailed { reason, .. } => panic!("the login failed: {reason}"), + ev => return ev, + } + } +} + +/// The reference builds `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the glue's +/// `CreateCharacter`, which the stock glue calls only from the create screen's Okay; so a login to +/// an account with no characters parks at select on the empty list and creates nothing. +#[test] +fn an_empty_account_parks_at_select_without_creating_a_character() { + let world_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let world = world_listener.local_addr().unwrap().to_string(); + let (keys_tx, keys_rx) = crossbeam_channel::unbounded(); + let sent = fake_world(world_listener, keys_rx); + let realmd = fake_realmd(world, keys_tx); + + let (events_tx, events) = crossbeam_channel::unbounded(); + let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::(); + let (login_tx, login_rx) = crossbeam_channel::unbounded(); + let (realm_tx, realm_rx) = crossbeam_channel::unbounded(); + let (pick_tx, pick_rx) = crossbeam_channel::unbounded(); + let cycle = thread::spawn(move || { + let parks = Parks { + login_rx, + realm_rx, + pick_rx, + }; + let ended = run( + &events_tx, + &writer_tx, + &parks, + &AtomicU64::new(0), + &Mutex::new(PingClock::default()), + &mut Default::default(), + ); + matches!(ended, Ok(Cycle::Repark)) + }); + + login_tx + .send(LoginRequest { + user: USER.into(), + pass: PASS.into(), + host: realmd, + generation: 0, + }) + .unwrap(); + match next_event(&events) { + SessionEvent::RealmList { realms } => assert_eq!(realms[0].name, REALM), + other => panic!("expected the realm list, got {other:?}"), + } + realm_tx.send(RealmRequest::Enter(REALM.into())).unwrap(); + match next_event(&events) { + SessionEvent::CharacterList { characters, .. } => assert!( + characters.is_empty(), + "the roster at select must be the account's, empty: {characters:?}" + ), + other => panic!("expected the roster at select, got {other:?}"), + } + // Select's Back ends the cycle, so the socket is done and its log complete. + pick_tx.send(CharRequest::Abandon).unwrap(); + assert!(cycle.join().unwrap(), "Back returns to the login park"); + let sent = sent.lock().unwrap().clone(); + assert!( + !sent.contains(&opcode::CMSG_CHAR_CREATE), + "the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}" + ); +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 128e5e6b..1b67ea2b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -73,8 +73,10 @@ out of scope is closed with the reason. `localhost:3724`). A scripted run has no default account: `WOW_USER`, `WOW_PASS` and `WOW_CHAR` name a test account on your server whose login kicks nobody, all three, either in the environment or in a `.probe-identity` file at the repo root (one per line, never - committed), and `scripts/smoke.sh` (the live login gate) refuses without them. The probes - drive the body with GM commands, so give that account the top GM level. + committed), and `scripts/smoke.sh` (the live login gate) refuses without them. `WOW_CHAR` + must already be on the account, since the client creates a character only when asked: + `WOW_PROBE_CHARCREATE= WOW_PROBE_CHARCREATE_KEEP=1`, run without `WOW_CHAR`, makes it. + The probes drive the body with GM commands, so give that account the top GM level. - **Running it unattended.** The rules are `docs/METHOD.md`, "The local server"; these are the switches. - `WOW_UNATTENDED=1` reconnects instead of waiting at a dialog, and exits non-zero on a login diff --git a/docs/MAP.md b/docs/MAP.md index a06f1408..d71c0234 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -182,7 +182,7 @@ - `WOW_CAPTURE_STABLE` — benilla-app/src/capture/mod.rs - `WOW_CAPTURE_UI` — benilla-app/src/capture/scenarios.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/video.rs - `WOW_CAST_TRACE` — benilla-app/src/net.rs -- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/net/io.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs +- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs - `WOW_CHARCREATE_DIALS` — benilla-app/src/char_create/mod.rs - `WOW_CHARCREATE_NAME` — benilla-app/src/char_create/mod.rs - `WOW_CHARCREATE_PICK` — benilla-app/src/capture/mod.rs, benilla-app/src/char_create/mod.rs From ce9d3f23cbb8e605b84707d7b4ddc1ad0f5a75ad Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:39:41 +0700 Subject: [PATCH 06/11] The ground tilt bounds its duration with the reference's two compares, so a cameraTerrainTiltTimeMin above cameraTerrainTiltTimeMax, or a NaN, no longer panics the client (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Follow Terrain on, a `cameraTerrainTiltTimeMin` above `cameraTerrainTiltTimeMax`, or a NaN in either, no longer panics the client on the first slope. The ground channel bounded its tween duration with `f32::clamp(lo, hi)`, which panics when `lo > hi` or a bound is NaN. - **The bound** is now the reference's two ordered compares (`0x50dd29`-`0x50dd83`, `lo = TimeMin × factor`, `hi = TimeMax × factor`): below `lo` takes `lo`, else at or above `hi`, or NaN, takes `hi`, else the duration stands. Nothing orders the pair, so an inverted one is an odd duration and never a fault. `lo` is compared unrounded, as it stays on the x87 stack (`0x50dd4c`), and `hi` as the f32 it is stored as (`0x50dd40`). - **In flight too.** The armer `0x512490` returns 1 when the request matches the one in flight (`0x512555`), and the caller bounds the duration on any nonzero return (`0x50dd21`), so the bound runs again every frame a tilt is in flight. For an ordered pair that changes nothing; an inverted pair leans for `TimeMax × factor`, and a TimeMax changed mid-tilt bounds the tilt in flight. - **The floor** that keeps a zero or NaN duration from stalling the tween moved from the stored duration to the divide, so the in-flight bound reads the raw value the reference reads. Checked: nine tests, each derived from the reference's rule rather than the code, covering an inverted pair below, between and above, a NaN in each input, the unrounded `lo` compare, the in-flight bound, and the issue's case end to end (Min 20, Max 1 on a 20° slope). Each fails with its piece undone, the first ones with the old `clamp` panic (`min > max, or either was NaN`). The bound matches the reference's bytes over 20M random inputs with no bit difference. Gates green on macOS; no platform seam touched. Fixes #304 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../benilla-app/src/player/camera_channel.rs | 192 ++++++++++++++++-- .../benilla-app/src/player/camera_dynamics.rs | 36 +++- 2 files changed, 214 insertions(+), 14 deletions(-) diff --git a/crates/benilla-app/src/player/camera_channel.rs b/crates/benilla-app/src/player/camera_channel.rs index 80425aff..bbc620ee 100644 --- a/crates/benilla-app/src/player/camera_channel.rs +++ b/crates/benilla-app/src/player/camera_channel.rs @@ -19,7 +19,7 @@ /// four channels and the `0x5107f0`/`0x5106f0` displacement predicates. pub(super) const CHANNEL_EPS: f32 = 0.001; -/// One `arm` request: the armer's four arguments plus the ground channel's duration clamp. +/// One `arm` request: the armer's four arguments plus the ground channel's duration bound. #[derive(Clone, Copy, Debug)] pub(super) struct Arm { /// In the live value's units. @@ -29,9 +29,26 @@ pub(super) struct Arm { pub(super) factor: f32, /// Live-value units per second: `cvar.to_radians()` for the angular channels, yd/s for height. pub(super) rate: f32, - /// The ground channel's bound: after the arm, `0x50dd29` clamps `[cam+0x1ac]` between - /// `Factor × cameraTerrainTiltTimeMin` and `Factor × cameraTerrainTiltTimeMax`. - pub(super) duration: Option<(f32, f32)>, + /// The ground channel's `(cameraTerrainTiltTimeMin, cameraTerrainTiltTimeMax)`, seconds, which + /// [`tilt_bound`] scales by `factor` to bound the duration. + pub(super) bound: Option<(f32, f32)>, +} + +/// The ground channel's duration bound, `0x50dd29`-`0x50dd83`, with `lo = TimeMin × factor` and +/// `hi = TimeMax × factor`: below `lo` takes `lo`, else at or above `hi`, or NaN, takes `hi`, else +/// the duration stands. Nothing orders the pair, so an inverted one is an odd duration, not a fault. +fn tilt_bound(duration: f32, (time_min, time_max): (f32, f32), factor: f32) -> f32 { + // `lo` is compared unrounded, live on the x87 stack (`0x50dd4c`); `hi` is stored as an f32 + // first (`0x50dd40`). Only an inverted pair can see the difference. + let lo = f64::from(time_min) * f64::from(factor); + let hi = time_max * factor; + if f64::from(duration) < lo { + lo as f32 + } else if duration < hi { + duration + } else { + hi + } } impl Arm { @@ -42,7 +59,7 @@ impl Arm { delay: 0.0, factor: 1.0, rate, - duration: None, + bound: None, } } } @@ -122,11 +139,16 @@ impl SmoothChannel { self.live += two_pi; } } - if let Some(f) = self.flight { + if let Some(f) = self.flight.as_mut() { if (self.to - arm.target).abs() < CHANNEL_EPS && (f.memo.0 - arm.delay).abs() < CHANNEL_EPS && (f.memo.1 - arm.factor).abs() < CHANNEL_EPS { + // The bound runs on this return too (`0x512555` returns 1, `0x50dd21` tests it), + // so it bounds the duration in flight again every frame. + if let Some(bound) = arm.bound { + f.duration = tilt_bound(f.duration, bound, arm.factor); + } return Armed::Already; } } @@ -138,15 +160,15 @@ impl SmoothChannel { return Armed::AtRest; } let mut duration = gap / arm.rate.max(f32::EPSILON) * arm.factor; - if let Some((lo, hi)) = arm.duration { - duration = duration.clamp(lo, hi); + if let Some(bound) = arm.bound { + duration = tilt_bound(duration, bound, arm.factor); } self.from = self.live; self.to = arm.target; self.flight = Some(Flight { elapsed: 0.0, delay: arm.delay, - duration: duration.max(f32::EPSILON), + duration, memo: (arm.delay, arm.factor), }); Armed::Started @@ -158,7 +180,10 @@ impl SmoothChannel { f.elapsed += dt; let t = f.elapsed - f.delay; if t >= 0.0 { - let s = t / f.duration; + // The floor lands a zero or NaN duration on the target. The reference's ground step + // divides by it raw (`0x50f45e`) and eases whenever C0 is set (`0x50f46f`), which a + // NaN `s` sets, so a NaN duration writes NaN into its live value. + let s = t / f.duration.max(f32::EPSILON); if s >= 1.0 { self.live = self.to; self.flight = None; @@ -230,7 +255,7 @@ mod tests { delay: 0.0, factor, rate, - duration: None + bound: None }), Armed::Started ); @@ -302,7 +327,7 @@ mod tests { delay: 0.5, factor: 1.0, rate: 1.0, - duration: None, + bound: None, }); let frames = run(&mut c, 0.45); assert!(frames.iter().all(|v| *v == 0.0), "held for the delay"); @@ -318,7 +343,7 @@ mod tests { delay: 0.0, factor: 1.0, rate: 7.5, - duration: Some((0.1, 0.5)), + bound: Some((0.1, 0.5)), }); let frames = run(&mut c, 0.6); let arrived = frames @@ -330,4 +355,145 @@ mod tests { "|Δ|/rate would be 13.3 s; the bound caps it at 0.5" ); } + + // The expectations below are `0x50dd29`-`0x50dd83` read directly: `lo = TimeMin × factor`, + // `hi = TimeMax × factor`; below `lo` → `lo`, else at or above `hi` or NaN → `hi`, else as is. + + #[test] + fn an_ordered_tilt_bound_keeps_its_clamp() { + for (duration, factor, expected) in [ + (1.0, 1.0, 3.0), + (5.0, 1.0, 5.0), + (10.0, 1.0, 10.0), + (20.0, 1.0, 10.0), + (1.0, 2.0, 6.0), + (10.0, 2.0, 10.0), + (25.0, 2.0, 20.0), + ] { + assert_eq!(tilt_bound(duration, (3.0, 10.0), factor), expected); + } + } + + #[test] + fn an_inverted_tilt_bound_gives_the_reference_duration() { + // `cameraTerrainTiltTimeMin 20` over `cameraTerrainTiltTimeMax 1`: at factor 2, lo 40, hi 2. + for (duration, expected) in [ + (1.0, 40.0), // below both: under lo + (10.0, 40.0), // between hi and lo: still under lo + (40.0, 2.0), // at lo: not under it, and at or above hi + (100.0, 2.0), // above both + ] { + assert_eq!(tilt_bound(duration, (20.0, 1.0), 2.0), expected); + } + assert_eq!(tilt_bound(0.5, (20.0, 1.0), 1.0), 20.0); + assert_eq!(tilt_bound(30.0, (20.0, 1.0), 1.0), 1.0); + } + + #[test] + fn a_nan_tilt_lo_is_no_floor() { + // No duration is under a NaN lo. + assert_eq!(tilt_bound(1.0, (f32::NAN, 10.0), 1.0), 1.0); + assert_eq!(tilt_bound(20.0, (f32::NAN, 10.0), 1.0), 10.0); + } + + #[test] + fn a_nan_tilt_hi_is_the_answer_above_lo() { + assert_eq!(tilt_bound(1.0, (3.0, f32::NAN), 1.0), 3.0); + assert!(tilt_bound(5.0, (3.0, f32::NAN), 1.0).is_nan()); + // A NaN factor makes both bounds NaN, so hi. + assert!(tilt_bound(5.0, (3.0, 10.0), f32::NAN).is_nan()); + } + + #[test] + fn a_nan_tilt_duration_takes_hi() { + // Under neither bound, so at or above hi. + assert_eq!(tilt_bound(f32::NAN, (3.0, 10.0), 1.0), 10.0); + } + + #[test] + fn the_tilt_bound_compares_lo_unrounded() { + // TimeMin 20 × (1 + 2⁻²³) is 20 + 1.25 ulp, which rounds down to 20 + 1 ulp as an f32. + let factor = f32::from_bits(0x3f80_0001); + let lo_f32 = f32::from_bits(0x41a0_0001); + assert_eq!(20.0 * factor, lo_f32); + assert!(f64::from(20.0_f32) * f64::from(factor) > f64::from(lo_f32)); + // A duration of exactly the rounded lo is under the unrounded one, so it takes lo; an f32 + // compare would pass it on to the hi of TimeMax 1 below it. + assert_eq!( + tilt_bound(lo_f32, (20.0, 1.0), factor).to_bits(), + lo_f32.to_bits() + ); + } + + /// Arm and step once a frame, as the terrain tilt drives the ground channel, until it lands; + /// the seconds it took. + fn arrival(c: &mut SmoothChannel, arm: &Arm, limit: f32) -> Option { + let dt = 1.0 / 60.0; + (1..=(limit / dt).round() as usize).find_map(|frame| { + c.arm(arm); + c.advance(dt); + (!c.in_flight()).then_some(frame as f32 * dt) + }) + } + + #[test] + fn an_inverted_tilt_pair_arms_and_is_bounded_again_in_flight() { + let arm = Arm { + target: 1.0, + delay: 0.0, + factor: 1.0, + rate: 1.0, + bound: Some((20.0, 1.0)), + }; + // The 1 s tween is under lo 20, so the arm takes 20. + let mut c = SmoothChannel::default(); + assert_eq!(c.arm(&arm), Armed::Started); + assert_eq!(c.flight.map(|f| f.duration), Some(20.0)); + // The next frame's arm is already arming this, and the bound runs again: 20 is not under + // lo, and is at or above hi 1, so 1. + assert_eq!(c.arm(&arm), Armed::Already); + assert_eq!(c.flight.map(|f| f.duration), Some(1.0)); + let took = arrival(&mut SmoothChannel::default(), &arm, 30.0).expect("lands"); + assert!( + (took - 1.0).abs() < 0.05, + "lands at hi, 1 s, took {took:.2}s" + ); + } + + #[test] + fn a_tilt_bound_changed_in_flight_bounds_the_tween_again() { + let mut arm = Arm { + target: 100.0, + delay: 0.0, + factor: 1.0, + rate: 1.0, + bound: Some((3.0, 10.0)), + }; + let mut c = SmoothChannel::default(); + assert_eq!(c.arm(&arm), Armed::Started); + assert_eq!(c.flight.map(|f| f.duration), Some(10.0)); + // `cameraTerrainTiltTimeMax 5` mid-tween. + arm.bound = Some((3.0, 5.0)); + assert_eq!(c.arm(&arm), Armed::Already); + assert_eq!(c.flight.map(|f| f.duration), Some(5.0)); + } + + #[test] + fn a_nan_tilt_duration_lands_on_the_target() { + let arm = Arm { + target: 5.0, + delay: 0.0, + factor: 1.0, + rate: 1.0, + bound: Some((3.0, f32::NAN)), + }; + let mut c = SmoothChannel::default(); + assert_eq!(c.arm(&arm), Armed::Started); + assert!( + c.flight.is_some_and(|f| f.duration.is_nan()), + "the bound's answer is the NaN hi" + ); + assert_eq!(c.advance(1.0 / 60.0), 5.0); + assert!(!c.in_flight()); + } } diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index c11154c7..285a431e 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -421,7 +421,7 @@ impl TerrainTilt { delay, factor, rate, - duration: Some((cfg.tilt_time_min * factor, cfg.tilt_time_max * factor)), + bound: Some((cfg.tilt_time_min, cfg.tilt_time_max)), }); } self.ground.advance(dt) @@ -878,6 +878,40 @@ mod tests { assert!(zeroed.abs() < CHANNEL_EPS, "levels off, at {zeroed}"); } + /// `cameraTerrainTiltTimeMin 20` over `cameraTerrainTiltTimeMax 1`, a pair 1.12.1 accepts, onto + /// a 20° slope: the first arm takes `20 × Factor`, and every later one bounds that to + /// `1 × Factor`, since it is not under lo and is at or above hi (`0x50dd29`). + #[test] + fn an_inverted_tilt_time_pair_leans_over_the_max_times_the_factor() { + use super::super::camera::FollowStyle; + let cfg = CameraOptions { + terrain_tilt: true, + tilt_time_min: 20.0, + tilt_time_max: 1.0, + ..CameraOptions::default() + }; + let target = TerrainTilt::slope_to_pitch(0.5); + // Walking is the Factor 1 row, falling the 0.75 one. + for (move_flags, factor) in [(mf::FORWARD, 1.0), (mf::FORWARD | mf::FALLING, 0.75)] { + let subject = SubjectState { + move_flags, + ..SubjectState::default() + }; + let mut t = TerrainTilt::default(); + let took = (1..=600) + .find_map(|frame| { + let p = t.advance(|| 0.5, true, &subject, FollowStyle::Smart, &cfg, 1.0 / 60.0); + (p == target).then_some(frame as f32 / 60.0) + }) + .expect("the lean arrives"); + let expected = cfg.tilt_time_max * factor; + assert!( + (took - expected).abs() < 0.05, + "Factor {factor}: {expected} s, took {took:.2}s" + ); + } + } + #[test] fn head_bob_arms_on_the_movement_command_word_and_on_the_mouse_chord() { use super::super::camera::follow_cmd as c; From 15ca25b6f54a77f763eaa6831bed5dd0c66d15d6 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:01:33 +0700 Subject: [PATCH 07/11] A released player's corpse takes a resurrection: the targeting cursor, the pick and the click bind it as 1.12.1 does (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Resurrection, Redemption or Rebirth armed and nothing selected, the targeting cursor takes a released party member's corpse: it lights over the corpse in range, and a click casts on it. Before, the cursor stayed grey over a corpse and a click bound nothing. - **The pick** (`0x480816`, `0x6e6260`). While targeting, a corpse is a candidate only under a word in `0x8600`: under `0x8000` a corpse that is not bones and passes `0x6067d0`, and under `0x200`, tried only without `0x8000`, one that is not bones and fails it. Outside targeting every corpse stays a candidate, as before. - **The reaction gate `0x6067d0`**: the caster's faction template toward the corpse owner's race template (`ChrRaces`, as `0x5d7120` reads it), friendly at 4 and up. The faction catalog now carries each race's template, and the PvP team loader reads it from there. - **The cursor**: `0x6e6460`'s corpse leg (`0x6e6719`-`0x6e676d`) with its range tail. `GetMinMaxRange 0x6e3480` pads a corpse like a unit, reading the caster's own reach twice (`0x6e3605`-`0x6e361e`). - **The click**: `BindTarget`'s corpse arm (`0x6e5f89`-`0x6e6020`), the `0x8000` arm then the `0x200` arm, through the same range merge as a unit (`0x6e602c`). Out of range raises the error and keeps the cursor armed, as the unit arm already does. - **The wire**: `CMSG_CAST_SPELL` with the corpse bit and the corpse's packed guid, which the client writes for any mask bit in `0x8a02` (`0x7e4e10`) and vmangos reads for either corpse bit (`SpellCastTargetsInfo.cpp:156-157`); `CMSG_USE_ITEM` the same, for Goblin Jumper Cables. Checked: byte-exact goldens for both packets; unit tests for the pick filter, the cursor verdict (bones, a hostile corpse, out of range, the reach pad), the bind arms from a world click to the sent command, and the reaction gate on the shipped DBCs. Each fails with its piece undone. There is no live run: the probes cannot aim or click in the 3D world. Gates green on macOS; no platform seam touched. Fixes #450 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/net.rs | 6 + crates/benilla-app/src/net/io.rs | 5 + crates/benilla-app/src/spell/cast_send.rs | 8 + crates/benilla-app/src/spell/mod.rs | 11 +- .../benilla-app/src/spell/targeting/corpse.rs | 286 ++++++++++++++++++ .../benilla-app/src/spell/targeting/cursor.rs | 90 +++++- crates/benilla-app/src/spell/targeting/mod.rs | 126 ++++++-- .../benilla-app/src/spell/targeting/world.rs | 131 +++++++- crates/benilla-app/src/target/hover.rs | 156 ++++++++-- crates/benilla-app/src/target/mod.rs | 2 +- crates/benilla-app/src/target/relations.rs | 74 +++++ crates/benilla-formats/src/factions.rs | 62 +++- crates/benilla-formats/src/race_pvp_team.rs | 48 +-- crates/benilla-protocol/src/messages/items.rs | 7 + crates/benilla-protocol/src/messages/mod.rs | 5 +- .../benilla-protocol/src/messages/spells.rs | 54 ++++ .../src/messages/update_object/fields/mod.rs | 5 + .../src/world/writer/spells.rs | 14 + crates/benilla-protocol/tests/items.rs | 13 + 19 files changed, 992 insertions(+), 111 deletions(-) create mode 100644 crates/benilla-app/src/spell/targeting/corpse.rs diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 639cb436..f669ffe1 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -884,6 +884,12 @@ pub(crate) enum ClientCommand { spell_id: u32, target: Option, }, + /// `CMSG_CAST_SPELL` at a corpse: its bit and packed guid, a resurrection on a released player. + CastSpellCorpse { + spell_id: u32, + target: benilla_protocol::messages::CorpseTarget, + corpse_guid: u64, + }, /// `CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`; `dest` in WoW coordinates. CastSpellAtDest { spell_id: u32, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index d9245ce8..01290709 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -1029,6 +1029,11 @@ fn writer_loop( count, } => w.destroy_item(bag_index, slot, count), ClientCommand::CastSpell { spell_id, target } => w.cast_spell(spell_id, target), + ClientCommand::CastSpellCorpse { + spell_id, + target, + corpse_guid, + } => w.cast_spell_corpse(spell_id, target, corpse_guid), ClientCommand::CastSpellAtDest { spell_id, dest } => { w.cast_spell_at_dest(spell_id, dest) } diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index ef8068c4..9a9dd4b3 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -103,6 +103,8 @@ pub(crate) enum TargetedBind { Object(u64), /// The world click's unit guid. Unit(u64), + /// The world click's corpse guid and the corpse bit `BindTarget`'s corpse arm bound. + Corpse(u64, benilla_protocol::messages::CorpseTarget), } impl CastLadder<'_, '_> { @@ -132,6 +134,11 @@ impl CastLadder<'_, '_> { spell_id, target: Some(unit_guid), }, + TargetedBind::Corpse(corpse_guid, target) => ClientCommand::CastSpellCorpse { + spell_id, + target, + corpse_guid, + }, }, CastCommit::Item { bag_index, @@ -148,6 +155,7 @@ impl CastLadder<'_, '_> { TargetedBind::Item(guid) => UseItemTarget::Item(guid), TargetedBind::Object(guid) => UseItemTarget::Object(guid), TargetedBind::Unit(guid) => UseItemTarget::Unit(guid), + TargetedBind::Corpse(guid, target) => UseItemTarget::Corpse(target, guid), }, }, }; diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index eefb9fec..67cd2a67 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -32,7 +32,9 @@ pub(crate) use inflight::{ }; pub(crate) use mods::{SpellModifiers, OP_COST}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. -pub(crate) use targeting::{ground_cast_radius, PicksSelf, SpellTargeting, TargetingWants}; +pub(crate) use targeting::{ + ground_cast_radius, CorpsePick, PicksSelf, SpellTargeting, TargetingWants, +}; /// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a /// cast ended by a move, jump or Esc drops its cast bar the same frame. @@ -54,6 +56,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_observer(cast_target::on_cvar) .add_systems( Update, @@ -71,7 +74,11 @@ impl Plugin for SpellPlugin { targeting::feed_targeting_to_vm .in_set(UnitFeed) .after(crate::ui_pet::pet_stop_on_old_target_clear), - targeting::publish_picks_self.in_set(UiFeed), + ( + targeting::publish_picks_self, + targeting::publish_corpse_pick, + ) + .in_set(UiFeed), targeting::drain_stop_targeting.after(UiInput), // The item-target commit (`0x495d60`): after the input pass so a bag click // binds the same frame; outside the target chain, as its clicks never reach diff --git a/crates/benilla-app/src/spell/targeting/corpse.rs b/crates/benilla-app/src/spell/targeting/corpse.rs new file mode 100644 index 00000000..30ca4f64 --- /dev/null +++ b/crates/benilla-app/src/spell/targeting/corpse.rs @@ -0,0 +1,286 @@ +//! The targeting cursor's corpse leg: a released player's corpse under a word carrying a corpse +//! bit, `0x8000` (Resurrection, Redemption, Rebirth) or `0x200`. A dead player who has not released +//! is a unit, and the unit leg takes him. +//! +//! - The pick ([`pick_admits`]): `0x480816` admits a corpse only with pick flag `0x40`, which while +//! targeting only a word in `0x8600` sets (`0x6e6230`), and then only a corpse `0x6e6260` passes. +//! - The cursor ([`super::SpellTargeting::can_target_corpse`]): `0x6e6460`'s corpse leg +//! (`6e6719`–`6e676d`), then its range tail. +//! - The click ([`bind_target_corpse`]): `BindTarget 0x6e5b40`'s corpse arm (`6e5f89`–`6e6020`), +//! then the merge's range test, as for a unit. +//! +//! All three read two facts of the corpse, [`CorpseFacts`]: bones are never a spell target, and the +//! reaction gate `0x6067d0` splits the ally bit from the enemy bit. + +use bevy::prelude::*; + +use benilla_protocol::messages::CorpseTarget; + +use crate::net::ObjectStore; +use crate::spell::cast_send::TargetedBind; +use crate::target::Factions; + +/// `TARGET_FLAG_CORPSE_ALLY` and `TARGET_FLAG_CORPSE_ENEMY`, as the flag word carries them. +const WORD_ALLY: u16 = 0x8000; +const WORD_ENEMY: u16 = 0x0200; + +/// What the corpse legs read of a corpse. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CorpseFacts { + /// `CORPSE_FIELD_FLAGS & 1`, `CORPSE_FLAG_BONES` (`[desc+0x74]`). + pub(crate) bones: bool, + /// `0x6067d0(caster, corpse)`, [`crate::target::corpse_friendly`]. + pub(crate) friendly: bool, +} + +impl CorpseFacts { + pub(super) fn of( + store: &ObjectStore, + factions: Option<&Factions>, + self_store: Option<&ObjectStore>, + ) -> Self { + Self { + bones: store.0.corpse_is_bones(), + friendly: crate::target::corpse_friendly(store, factions, self_store), + } + } +} + +/// [`pick_admits`] over a corpse's store, for [`crate::target`]'s hover. +pub(crate) fn corpse_pick_admits( + word: u16, + store: &ObjectStore, + factions: Option<&Factions>, + self_store: Option<&ObjectStore>, +) -> bool { + pick_admits(word, CorpseFacts::of(store, factions, self_store)) +} + +/// `0x6e6260`, the pick's corpse filter while targeting: a word with the ally bit takes a corpse +/// that is not bones and that `0x6067d0` passes (`6e6291`–`6e62a7`); only a word without it tries +/// the enemy bit, on a corpse that is not bones and that `0x6067d0` fails (`6e62ad`–`6e62c6`). +/// Both bits are inside `0x8600`, so a corpse this admits was already a candidate (`0x6e6230`). +fn pick_admits(word: u16, f: CorpseFacts) -> bool { + if word & WORD_ALLY != 0 { + !f.bones && f.friendly + } else { + word & WORD_ENEMY != 0 && !f.bones && !f.friendly + } +} + +/// `BindTarget 0x6e5b40`'s corpse arm: the ally arm (`6e5f97`–`6e5fda`) binds a corpse that is not +/// bones and that `0x6067d0` passes, then the enemy arm (`6e5fdc`–`6e6020`) one it fails. Each +/// writes its bit to the wire mask (`6e5fc7`, `6e600c`), parks the corpse's guid and clears its bit +/// from the word. `None` binds nothing, silently, and the cursor stays (`6e6026`). The read-only +/// mirror `0x6e6460` (`6e6719`–`6e676d`) passes exactly the corpses this binds. +fn bind_arm(word: u16, f: CorpseFacts) -> Option { + if f.bones { + None + } else if word & WORD_ALLY != 0 && f.friendly { + Some(CorpseTarget::Ally) + } else if word & WORD_ENEMY != 0 && !f.friendly { + Some(CorpseTarget::Enemy) + } else { + None + } +} + +/// The world pick's corpse admission as the frame began, which [`crate::target`]'s hover reads: +/// `None` outside targeting, where the pick flags carry `0x5c` and so `0x40`, and every corpse is a +/// candidate (`0x480816`); while targeting, the standing word, through [`pick_admits`]. +#[derive(Resource, Default)] +pub(crate) struct CorpsePick(pub(crate) Option); + +/// Publish [`CorpsePick`] before the input pass, beside [`super::PicksSelf`]. +pub(crate) fn publish_corpse_pick( + targeting: Res, + mut pick: ResMut, +) { + let now = targeting.0.as_ref().map(|t| t.word); + if pick.0 != now { + pick.0 = now; + } +} + +impl super::SpellTargeting { + /// `0x6e6460`'s corpse leg with the range flag the hover passes (`48290b`): [`bind_arm`]'s + /// test, then min² ≤ d² ≤ max² (`6e668c`). + pub(crate) fn can_target_corpse(&self, entity: Entity, checks: &super::BindChecks) -> bool { + self.0.as_ref().is_some_and(|t| { + checks + .corpse_facts(entity) + .is_some_and(|f| bind_arm(t.word, f).is_some()) + && checks.corpse_range_refusal(t.spell_id, entity).is_none() + }) + } +} + +/// `BindTarget 0x6e5b40`'s corpse arm for the world click (`0x493540` at `4935d5`): a corpse no +/// arm binds leaves the cursor up; otherwise the merge's range test and the commit, with the +/// corpse's guid and its bit (`0x7e4e10` writes the guid for any mask bit in `0x8a02`). +/// +/// The reference sends only once the word is empty (`6e60c1`), so a word the corpse bit does not +/// empty waits for another click with the corpse parked. No player spell has one (every shipped +/// `0x8000` word with another bit is an NPC or test spell), and as the unit arm does, this binds +/// only a click that empties the word. +pub(super) fn bind_target_corpse( + ladder: &mut crate::spell::CastLadder, + checks: &super::BindChecks, + entity: Entity, + guid: u64, +) { + let Some((spell_id, commit, word)) = ladder.ground.pending() else { + return; + }; + let Some(bit) = checks.corpse_facts(entity).and_then(|f| bind_arm(word, f)) else { + return; + }; + if word & !bit.target_flag() != 0 { + return; + } + let range = checks.corpse_range_refusal(spell_id, entity); + super::merge( + ladder, + spell_id, + commit, + range, + TargetedBind::Corpse(guid, bit), + ); +} + +/// A human caster, and a human corpse and an orc corpse toward him, on the shipped rows of +/// `FactionTemplate.dbc` 1 and 2 and `ChrRaces.dbc` races 1 and 2. +#[cfg(test)] +pub(crate) mod fixture { + use std::collections::HashMap; + + use benilla_formats::{FactionCatalog, FactionTemplate}; + use benilla_protocol::ObjectFields; + + use crate::net::ObjectStore; + use crate::target::Factions; + + pub(crate) const HUMAN: u8 = 1; + pub(crate) const ORC: u8 = 2; + /// Resurrection rank 1: `Targets 0x8000`, range row 4. + pub(crate) const RESURRECTION: u32 = 2006; + /// A corpse guid, `HIGHGUID_CORPSE` 0xF101 (vmangos `ObjectGuid.h:76`). + pub(crate) const CORPSE: u64 = 0xF101_0000_0000_002A; + + /// Templates 1 (Human) and 2 (Orc): group, friend and enemy masks 3/2/12 and 5/4/10. + pub(crate) fn factions() -> Factions { + let tpl = |faction, group_mask, friend_group_mask, enemy_group_mask| FactionTemplate { + faction, + group_mask, + friend_group_mask, + enemy_group_mask, + enemies: [0; 4], + friends: [0; 4], + }; + Factions::from_catalog(FactionCatalog::from_rows( + HashMap::from([(1, tpl(1, 3, 2, 12)), (2, tpl(2, 5, 4, 10))]), + HashMap::from([(HUMAN, 1), (ORC, 2)]), + )) + } + + /// A human caster, template 1, with this `UNIT_FIELD_COMBATREACH` (field 130). + pub(crate) fn caster(reach: f32) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(&[(35, 1), (130, reach.to_bits())])) + } + + /// A released player's corpse: the race in `CORPSE_FIELD_BYTES_1` byte 1 (field 32) and + /// `CORPSE_FLAG_BONES` in `CORPSE_FIELD_FLAGS` (field 35). + pub(crate) fn corpse(race: u8, bones: bool) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(&[ + (32, u32::from(race) << 8), + (35, u32::from(bones)), + ])) + } + + /// Resurrection on range row 4, 0 to 30 yd. + pub(crate) fn spells() -> crate::ui_action::Spells { + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([( + RESURRECTION, + benilla_formats::SpellDisplay { + range_index: 4, + targets: 0x8000, + ..Default::default() + }, + )])); + spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([( + 4, + benilla_formats::SpellRange { + min: 0.0, + max: 30.0, + flags: 0, + }, + )])); + spells + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BONES: CorpseFacts = CorpseFacts { + bones: true, + friendly: true, + }; + const ALLY: CorpseFacts = CorpseFacts { + bones: false, + friendly: true, + }; + const ENEMY: CorpseFacts = CorpseFacts { + bones: false, + friendly: false, + }; + + /// `0x6e6260`: bones never, the ally bit only a friendly corpse, the enemy bit only an + /// unfriendly one, and only on a word without the ally bit. + #[test] + fn the_pick_admits_a_corpse_as_0x6e6260_does() { + assert!(pick_admits(0x8000, ALLY), "Resurrection over a friend"); + assert!(!pick_admits(0x8000, ENEMY), "never an enemy's corpse"); + assert!(!pick_admits(0x8000, BONES), "never bones"); + assert!(pick_admits(0x0200, ENEMY)); + assert!(!pick_admits(0x0200, ALLY)); + assert!(!pick_admits( + 0x0200, + CorpseFacts { + bones: true, + friendly: false + } + )); + // Both bits: the ally arm decides alone (`6e6291 jns`), so no enemy corpse. + assert!(pick_admits(0x8200, ALLY)); + assert!(!pick_admits(0x8200, ENEMY)); + // A word with no corpse bit admits none, `0x400` inside `0x8600` included. + for word in [0x0002, 0x0100, 0x0400, 0x0040, 0x4800] { + assert!(!pick_admits(word, ALLY), "{word:#06x}"); + assert!(!pick_admits(word, ENEMY), "{word:#06x}"); + } + } + + /// `BindTarget`'s corpse arm, which `0x6e6460`'s corpse leg mirrors: the ally arm then the + /// enemy arm, each on a corpse that is not bones. + #[test] + fn the_bind_takes_the_arm_the_reaction_picks() { + assert_eq!(bind_arm(0x8000, ALLY), Some(CorpseTarget::Ally)); + assert_eq!( + bind_arm(0x8000, ENEMY), + None, + "a hostile corpse under 0x8000" + ); + assert_eq!(bind_arm(0x8000, BONES), None, "bones"); + assert_eq!(bind_arm(0x0200, ENEMY), Some(CorpseTarget::Enemy)); + assert_eq!(bind_arm(0x0200, ALLY), None); + // Both bits: unlike the pick, the enemy arm still runs after the ally arm fails. + assert_eq!(bind_arm(0x8200, ENEMY), Some(CorpseTarget::Enemy)); + assert_eq!(bind_arm(0x8200, ALLY), Some(CorpseTarget::Ally)); + for word in [0x0002, 0x0100, 0x0400, 0x0040] { + assert_eq!(bind_arm(word, ALLY), None, "{word:#06x}"); + } + } +} diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index fbd3cfc9..044bd0f9 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -1,6 +1,7 @@ //! The cursor while a cast waits for its click: the ground point's range verdict //! (`CheckGroundPointInRange 0x6e6810`, in `0x4820f0`), the hovered object's validity (`0x6e6460`, -//! in `0x4828d0`) and the reticle's radius (`GetCurrentCastRadius 0x6e6350`). +//! in `0x4828d0`: a GameObject, a unit or a corpse) and the reticle's radius +//! (`GetCurrentCastRadius 0x6e6350`). use bevy::prelude::*; @@ -76,12 +77,13 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: /// /// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`, /// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is -/// [`SpellTargeting::can_target_unit`]. The world-item and corpse legs are not built. +/// [`SpellTargeting::can_target_unit`] and its corpse leg [`SpellTargeting::can_target_corpse`], +/// over a corpse the pick admitted under a word in `0x8600`. The world-item leg is not built. /// /// Every seam shows the `Cast` kind, so this reads the whole-word [`SpellTargeting::spell`]. pub(crate) fn drive_targeting_cursor( targeting: Res, - unit_checks: super::UnitBindChecks, + checks: super::BindChecks, occlusion: Res, hovered: Res, hovered_object: Res, @@ -104,7 +106,7 @@ pub(crate) fn drive_targeting_cursor( let row = range_row(spells.as_deref(), spell_id); let me = self_tf.single().ok().map(|tf| tf.translation); // The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject - // word's nearest GameObject, else the picked unit, else the terrain. + // word's nearest GameObject, else the picked unit or corpse, else the terrain. let able = if targeting.wants(TargetingWants::GameObject) && crate::target::go_is_nearest(&hovered, &hovered_object) { @@ -121,7 +123,12 @@ pub(crate) fn drive_targeting_cursor( .target .filter(|_| targeting.wants(TargetingWants::Unit)) { - targeting.can_target_unit(unit, &unit_checks) + targeting.can_target_unit(unit, &checks) + } else if let Some(corpse) = hovered + .corpse + .filter(|_| targeting.wants(TargetingWants::Corpse)) + { + targeting.can_target_corpse(corpse, &checks) } else if targeting.wants(TargetingWants::Location) { // `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast. match (occlusion.point, me) { @@ -433,4 +440,77 @@ mod tests { "inside the minimum → UnableCast" ); } + + /// `0x6e6460`'s corpse leg over a hovered corpse: bones, a hostile corpse under the ally bit + /// and a corpse out of range are grey. The range pads a corpse by the caster's reach twice + /// (`6e3605`–`6e361e`): Resurrection's 30 yd with a 3.0 reach is 36. + #[test] + fn the_corpse_leg_is_grey_for_bones_a_hostile_corpse_or_out_of_range() { + use super::super::corpse_fixture as fx; + use bevy::ecs::system::RunSystemOnce; + + let verdict = |word: u16, corpse: crate::net::ObjectStore, distance: f32| { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(crate::net::Reputations(Vec::new())); + world.init_resource::(); + world.insert_resource(fx::spells()); + world.insert_resource(fx::factions()); + world.spawn(( + SelfPlayer, + Transform::default(), + GlobalTransform::default(), + fx::caster(3.0), + )); + let body = world + .spawn(( + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + corpse, + )) + .id(); + world.insert_resource(crate::target::Hovered { + corpse: Some(body), + corpse_guid: Some(fx::CORPSE), + distance: 5.0, + ..Default::default() + }); + world.resource_mut::().enter( + fx::RESURRECTION, + crate::spell::CastCommit::Spell, + word, + ); + world + .run_system_once(drive_targeting_cursor) + .expect("the targeting cursor drives"); + !world.resource::().unable + }; + let friend = || fx::corpse(fx::HUMAN, false); + + assert!(verdict(0x8000, friend(), 10.0), "a friend's corpse → Cast"); + assert!( + !verdict(0x8000, fx::corpse(fx::HUMAN, true), 10.0), + "bones → UnableCast" + ); + assert!( + !verdict(0x8000, fx::corpse(fx::ORC, false), 10.0), + "a hostile corpse under the ally bit → UnableCast" + ); + assert!( + verdict(0x0200, fx::corpse(fx::ORC, false), 10.0), + "which the enemy bit takes" + ); + // Past the bare 30 and past 30 + 3.0 + 1.5, inside 30 + 2 × 3.0. + assert!(verdict(0x8000, friend(), 35.0), "inside the padded range"); + assert!( + !verdict(0x8000, friend(), 37.0), + "out of range → UnableCast" + ); + // A word outside `0x8600` has no corpse leg: the heal word over a corpse is state 0. + assert!(!verdict(0x0002, friend(), 10.0)); + } } diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index cfb5dafc..5529d2d1 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -3,21 +3,22 @@ //! word and each click seam asks it its own mask test ([`TargetingWants`]). //! //! - [`cursor`]: the hover verdict per seam. Terrain is `0x4820f0`'s `CheckGroundPointInRange -//! 0x6e6810`, a GameObject or a unit is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate -//! `0x5f8260` or [`SpellTargeting::can_target_unit`], then range), and a word no seam handles is -//! UnableCast. +//! 0x6e6810`, a GameObject, a unit or a corpse is `0x4828d0`'s `0x6e6460` (the spell-vs-lock +//! predicate `0x5f8260`, [`SpellTargeting::can_target_unit`] or +//! [`SpellTargeting::can_target_corpse`], then range), and a word no seam handles is UnableCast. //! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation //! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), -//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` ([`drain_spell_target_unit`]). +//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` +//! ([`drain_spell_target_unit`]), and whose corpse arm is [`corpse`]'s. //! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click //! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose //! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing. //! //! Every seam commits through [`crate::spell::CastLadder::commit_targeted`] (`SendCast 0x6e54f0`). -//! Only the unit arm has a range gate: the terrain and GameObject clicks send and the server -//! judges range, and `CheckGroundPointInRange` only colours the cursor. While targeting, the pick -//! flags come from the word alone, so a click over a unit with a dest-only word commits on the -//! ground behind it. +//! Only the unit and corpse arms have a range gate ([`merge`]): the terrain and GameObject clicks +//! send and the server judges range, and `CheckGroundPointInRange` only colours the cursor. While +//! targeting, the pick flags come from the word alone, so a click over a unit with a dest-only +//! word commits on the ground behind it. //! //! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]), //! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which @@ -25,10 +26,14 @@ //! spell (`UseAction 0x4e5ee0`). A cancel clears the word and sends nothing; movement never //! cancels (`0x515090`). +mod corpse; mod cursor; mod item; mod world; +#[cfg(test)] +pub(crate) use corpse::fixture as corpse_fixture; +pub(crate) use corpse::{corpse_pick_admits, publish_corpse_pick, CorpsePick}; pub(crate) use cursor::{drive_targeting_cursor, ground_cast_radius}; pub(crate) use item::{commit_item_cast_on_pick, EnchantConfirmItem}; pub(crate) use world::{commit_ground_cast_on_click, commit_object_cast_on_click}; @@ -37,12 +42,14 @@ use bevy::prelude::*; use benilla_world::interact::WorldRightPress; -/// The inputs of the cursor's two unit functions, [`SpellTargeting::can_target_unit`] and -/// [`bind_target_unit`]: the relation checks the cast arm's selection bind runs -/// ([`super::cast_target::unit_word_binds`]) and the pre-send range gate's inputs -/// ([`super::cast_target::RangeInputs`]), read as [`super::cast_target::CastTargeting`] reads them. +/// The inputs of `BindTarget 0x6e5b40`'s unit and corpse arms and of their read-only mirror +/// `0x6e6460` ([`SpellTargeting::can_target_unit`], [`bind_target_unit`], +/// [`SpellTargeting::can_target_corpse`], [`corpse::bind_target_corpse`]): the relation checks the +/// cast arm's selection bind runs ([`super::cast_target::unit_word_binds`]), the corpse's two +/// facts, and the pre-send range gate's inputs ([`super::cast_target::RangeInputs`]), read as +/// [`super::cast_target::CastTargeting`] reads them. #[derive(bevy::ecs::system::SystemParam)] -pub(crate) struct UnitBindChecks<'w, 's> { +pub(crate) struct BindChecks<'w, 's> { stores: Query<'w, 's, &'static crate::net::ObjectStore>, index: Option>, self_q: Query< @@ -60,7 +67,7 @@ pub(crate) struct UnitBindChecks<'w, 's> { spells: Option>, } -impl UnitBindChecks<'_, '_> { +impl BindChecks<'_, '_> { fn is_self(&self, entity: Entity) -> bool { self.self_q .iter() @@ -93,8 +100,27 @@ impl UnitBindChecks<'_, '_> { /// asks ([`super::cast_target::RangeInputs::refusal`]); our own body is distance 0. An /// unknown spell or a missing row passes. fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option { - let spells = self.spells.as_deref()?; - let def = spells.catalog.get(spell_id)?; + let mut range = self.range_inputs(entity); + range.target_reach = self + .stores + .get(entity) + .ok() + .map(|s| s.0.unit_combat_reach()); + self.refusal(spell_id, range) + } + + /// The same compare against a corpse. `GetMinMaxRange 0x6e3480` pads a corpse's bounds as a + /// unit's (`6e35fe`), but with no unit to read the second reach from it reads the caster's + /// again (`6e3605`–`6e361e`). + fn corpse_range_refusal(&self, spell_id: u32, entity: Entity) -> Option { + let mut range = self.range_inputs(entity); + range.target_reach = Some(range.self_reach); + self.refusal(spell_id, range) + } + + /// Our position and reach and the candidate's position, each position the pose the hover + /// picks against. + fn range_inputs(&self, entity: Entity) -> super::cast_target::RangeInputs { let me = self.self_q.iter().next(); let mut range = super::cast_target::RangeInputs { self_pos: me @@ -105,19 +131,30 @@ impl UnitBindChecks<'_, '_> { .get(entity) .ok() .map(GlobalTransform::translation), - target_reach: self - .stores - .get(entity) - .ok() - .map(|s| s.0.unit_combat_reach()), ..Default::default() }; if let Some((_, Some(store))) = me { range.self_reach = store.0.unit_combat_reach(); } + range + } + + fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option { + let spells = self.spells.as_deref()?; + let def = spells.catalog.get(spell_id)?; range.refusal(def, spells.ranges.get(def.range_index)) } + /// What the corpse legs read of this corpse: `CORPSE_FLAG_BONES` and the reaction gate + /// `0x6067d0`. A corpse whose store has not streamed binds nothing. + fn corpse_facts(&self, entity: Entity) -> Option { + Some(corpse::CorpseFacts::of( + self.stores.get(entity).ok()?, + self.factions.as_deref(), + self.self_q.iter().next().and_then(|(_, s)| s), + )) + } + /// The caster under a spell with `AttributesEx & 0x80000` (`6e6507`). fn excluded_caster(&self, spell_id: u32, entity: Entity) -> bool { self.is_self(entity) @@ -135,6 +172,7 @@ impl UnitBindChecks<'_, '_> { /// - `Item`: `TargetingWantsItem 0x6e6330`, `word & 0x4010`, the bag and paper-doll clicks. /// - `GameObject`: `TargetingWantsGameObject 0x6e62d0`, `word & 0x4800`, the world object click. /// - `Unit`: `SpellCanTargetUnit 0x6e6460`, the unit arm of the world click. +/// - `Corpse`: `0x6e6230`, `word & 0x8600`, the pick's corpse flag `0x40` ([`corpse`]). /// /// The masks overlap on `TARGET_FLAG_LOCKED`, so a lock spell answers both the item and the /// GameObject seam; the reference settles it only at the click, where `BindTarget 0x6e5b40` picks @@ -145,6 +183,7 @@ pub(crate) enum TargetingWants { Item, GameObject, Unit, + Corpse, } /// The unit-shaped bits of the flag_word, the first gate `SpellCanTargetUnit` tests before it runs @@ -158,6 +197,7 @@ impl TargetingWants { Self::Item => 0x4010, Self::GameObject => 0x4800, Self::Unit => UNIT_WORD_BITS, + Self::Corpse => 0x8600, }; word & mask != 0 } @@ -203,7 +243,7 @@ impl SpellTargeting { /// `BindTarget`'s relation checks, the caster refused under `AttributesEx & 0x80000` /// (`6e6507`), then min² ≤ d² ≤ max² (`6e677c`–`6e6802`), so a unit inside the minimum is out /// too. There is no line-of-sight test. - pub(crate) fn can_target_unit(&self, entity: Entity, checks: &UnitBindChecks) -> bool { + pub(crate) fn can_target_unit(&self, entity: Entity, checks: &BindChecks) -> bool { self.0.as_ref().is_some_and(|t| { checks.relations_clear(t.word, entity) && !checks.excluded_caster(t.spell_id, entity) @@ -321,7 +361,7 @@ pub(crate) fn publish_picks_self( /// and re-arm in one frame still counts. pub(crate) fn feed_targeting_to_vm( targeting: Res, - checks: UnitBindChecks, + checks: BindChecks, tokens: crate::ui_unit::UnitTokens, selection: Res, mut last: Local>>, @@ -361,12 +401,11 @@ pub(crate) fn drain_stop_targeting( /// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and /// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit -/// failing the relation checks binds nothing and the cursor stays up; one out of the spell's range -/// raises "Out of range." or "Target too close" (`6e6063`); otherwise the cast commits at it. The +/// failing the relation checks binds nothing and the cursor stays up; otherwise the merge. The /// player's selection never moves. fn bind_target_unit( ladder: &mut crate::spell::CastLadder, - checks: &UnitBindChecks, + checks: &BindChecks, entity: Entity, guid: u64, ) { @@ -376,15 +415,36 @@ fn bind_target_unit( if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) { return; } - if let Some(reason) = checks.range_refusal(spell_id, entity) { - // The reference's abort here (`6e6089`) also sends CMSG_CANCEL_CAST and strands the - // cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and working. - debug!("ui_action: cast {spell_id} not bound at {guid:#x} — range ({reason:#x})"); + let range = checks.range_refusal(spell_id, entity); + merge( + ladder, + spell_id, + commit, + range, + super::cast_send::TargetedBind::Unit(guid), + ); +} + +/// `BindTarget`'s merge (`6e602c`–`6e60d7`): a bind whose mask is in `0x8202`, a unit or a corpse, +/// runs the range test `0x6e47b0` (`6e6063`). Out of range raises "Out of range." or "Target too +/// close" and binds nothing; otherwise the emptied word commits (`SendCast 0x6e54f0`). The +/// reference's range failure also aborts the cast (`6e6089`), which sends CMSG_CANCEL_CAST and +/// strands the cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and +/// working. +fn merge( + ladder: &mut crate::spell::CastLadder, + spell_id: u32, + commit: super::cast_send::CastCommit, + range: Option, + bound: super::cast_send::TargetedBind, +) { + if let Some(reason) = range { + debug!("ui_action: cast {spell_id} not bound at {bound:x?} — range ({reason:#x})"); ladder.cast_errors.push_local(spell_id, reason); return; } - debug!("ui_action: cast {spell_id} committed at unit {guid:#x}"); - ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Unit(guid)); + debug!("ui_action: cast {spell_id} committed at {bound:x?}"); + ladder.commit_targeted(spell_id, commit, bound); } /// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the @@ -395,7 +455,7 @@ pub(crate) fn drain_spell_target_unit( script: Option>, tokens: crate::ui_unit::UnitTokens, selection: Res, - checks: UnitBindChecks, + checks: BindChecks, mut ladder: crate::spell::CastLadder, ) { let Some(mut script) = script else { diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index 47cbe35b..cef9281a 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -2,13 +2,14 @@ //! the pick flags (`0x481050`) come only from the word `0xcecac0`, so the word picks the leg: //! //! - terrain (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`): [`commit_ground_cast_on_click`] -//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject or -//! unit arm the picked object's type chooses: [`commit_object_cast_on_click`] +//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject, +//! unit or corpse arm the picked object's type chooses: [`commit_object_cast_on_click`] //! //! A location-only word yields pick flags 3, whose `& 0x7c == 0` disables the object pick, so an //! AoE reticle clicks through a chest. The terrain and GameObject arms gate on nothing, not range, -//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit arm -//! runs `BindTarget`'s relation and range checks ([`super::bind_target_unit`]). +//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit and +//! corpse arms run `BindTarget`'s relation and range checks ([`super::bind_target_unit`], +//! [`super::corpse::bind_target_corpse`]). use bevy::prelude::*; @@ -75,15 +76,16 @@ pub(crate) fn commit_ground_cast_on_click( /// (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`, with no gate before the send, /// not range nor the lock: the refusal is the server's `SMSG_CAST_RESULT`. The right-click path /// ([`crate::target::click`], `0x5f33e0`) does resolve the lock and can refuse locally. Otherwise -/// the picked unit goes to the unit arm ([`super::bind_target_unit`]); a unit click under a lock -/// word binds nothing there, since every unit arm tests a bit the word lacks. +/// the picked unit goes to the unit arm ([`super::bind_target_unit`]) and the picked corpse to the +/// corpse arm ([`super::corpse::bind_target_corpse`]); a unit click under a lock word binds nothing +/// there, since every unit arm tests a bit the word lacks. /// /// Runs after `select_on_click`, as the terrain commit does. pub(crate) fn commit_object_cast_on_click( mut clicks: MessageReader, // The press's pick: the object the gesture started on. press: Res, - checks: super::UnitBindChecks, + checks: super::BindChecks, mut ladder: crate::spell::CastLadder, ) { let (hovered, hovered_object) = (press.hovered, press.object); @@ -101,6 +103,8 @@ pub(crate) fn commit_object_cast_on_click( { if let Some((entity, guid)) = hovered.target.zip(hovered.guid) { super::bind_target_unit(&mut ladder, &checks, entity, guid); + } else if let Some((entity, guid)) = hovered.corpse.zip(hovered.corpse_guid) { + super::corpse::bind_target_corpse(&mut ladder, &checks, entity, guid); } return; } @@ -424,6 +428,119 @@ mod tests { assert!(!world.resource::().active()); } + /// `BindTarget`'s corpse arm from a world click on a released player's corpse: the ally bit on + /// a friend's corpse commits with `0x8000` and the corpse guid, the enemy bit on a hostile one + /// with `0x200`; bones, a hostile corpse under the ally bit and a word the bit does not empty + /// wait silently; out of range raises "Out of range." and waits. An item's corpse cast (Goblin + /// Jumper Cables) commits as `CMSG_USE_ITEM` with the same block. + #[test] + fn a_click_on_a_released_corpse_binds_the_corpse_arm() { + use super::super::corpse_fixture as fx; + use benilla_protocol::messages::{CorpseTarget, UseItemTarget}; + let commit = crate::spell::cast_send::CastCommit::Spell; + + let clicked = |word: u16, + commit: crate::spell::cast_send::CastCommit, + corpse: crate::net::ObjectStore, + distance: f32| { + let (mut world, rx, id) = fixture(); + world.insert_resource(fx::spells()); + world.insert_resource(fx::factions()); + world.spawn(( + crate::net::SelfPlayer, + GlobalTransform::default(), + fx::caster(1.5), + )); + let body = world + .spawn(( + GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)), + corpse, + )) + .id(); + world.resource_mut::().hovered = Hovered { + corpse: Some(body), + corpse_guid: Some(fx::CORPSE), + distance: 5.0, + ..Hovered::default() + }; + world.resource_mut::().enter( + fx::RESURRECTION, + commit, + word, + ); + click(&mut world, id); + let sent = rx.try_recv().ok(); + let errors = + std::mem::take(&mut world.resource_mut::().0); + let armed = world.resource::().active(); + (sent, errors, armed) + }; + let friend = || fx::corpse(fx::HUMAN, false); + let foe = || fx::corpse(fx::ORC, false); + + let (sent, errors, armed) = clicked(0x8000, commit, friend(), 10.0); + assert!( + matches!( + sent, + Some(ClientCommand::CastSpellCorpse { + spell_id: fx::RESURRECTION, + target: CorpseTarget::Ally, + corpse_guid: fx::CORPSE, + }) + ), + "{sent:?}" + ); + assert!(errors.is_empty() && !armed, "the bind ends the cursor"); + + let (sent, _, _) = clicked(0x0200, commit, foe(), 10.0); + assert!(matches!( + sent, + Some(ClientCommand::CastSpellCorpse { + target: CorpseTarget::Enemy, + .. + }) + )); + + // Silent refusals: nothing sent, no error, the cursor still up (`6e6026`). + for (word, corpse, what) in [ + (0x8000, fx::corpse(fx::HUMAN, true), "bones"), + (0x8000, foe(), "a hostile corpse under the ally bit"), + (0x8002, friend(), "a word the corpse bit does not empty"), + (0x0002, friend(), "a word with no corpse bit"), + ] { + let (sent, errors, armed) = clicked(word, commit, corpse, 10.0); + assert!(sent.is_none() && errors.is_empty() && armed, "{what}"); + } + + // Out of range (30 + 2 × 1.5 = 33): "Out of range." and the cursor stays. + let (sent, errors, armed) = clicked(0x8000, commit, friend(), 40.0); + assert!(sent.is_none() && armed); + assert_eq!( + errors, + vec![crate::ui_action::CastFail::local(fx::RESURRECTION, 0x59)] + ); + + // An item's cast: the same corpse block under `CMSG_USE_ITEM`. + let cables = crate::spell::cast_send::CastCommit::Item { + bag_index: 255, + slot: 24, + entry: 7148, + spell_index: 0, + on_object: None, + }; + let (sent, _, _) = clicked(0x8000, cables, friend(), 10.0); + assert!( + matches!( + sent, + Some(ClientCommand::UseItem { + target: UseItemTarget::Corpse(CorpseTarget::Ally, fx::CORPSE), + .. + }) + ), + "{sent:?}" + ); + } + /// Every way the object click must not commit. #[test] fn the_object_commit_holds_its_fire() { diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 4f890139..d5d2dd94 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -83,14 +83,45 @@ pub(super) struct PickPose<'w, 's> { rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>, } -/// The local identity and whether the pick takes it, one [`SystemParam`] under the picker's param -/// limit. +/// The local identity and what of it and of the corpses the pick takes, one [`SystemParam`] under +/// the picker's param limit. #[derive(bevy::ecs::system::SystemParam)] -pub(super) struct TargetHoverState<'w> { +pub(super) struct TargetHoverState<'w, 's> { /// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause. self_guid: Res<'w, crate::net::SelfGuid>, /// `0x6e61a0`'s pick flag `0x20`, as the frame began. picks_self: Res<'w, crate::spell::PicksSelf>, + /// The corpse leg's word, as the frame began. + corpse_pick: Res<'w, crate::spell::CorpsePick>, + /// The corpse reaction gate's inputs (`0x6067d0`). + factions: Option>, + self_store: Query<'w, 's, &'static ObjectStore, With>, +} + +impl TargetHoverState<'_, '_> { + /// Whether an object of this kind is a candidate of this pick, the posed pass and the box + /// fallback alike: every unit and player, and a corpse (`0x480816`) always outside targeting, + /// whose pick flags carry `0x40`, and while targeting only one `0x6e6260` passes. A corpse + /// whose store has not streamed is refused then. + fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool { + match kind { + EntityKind::Unit | EntityKind::Player => true, + EntityKind::Corpse => { + let Some(word) = self.corpse_pick.0 else { + return true; + }; + store.is_some_and(|store| { + crate::spell::targeting::corpse_pick_admits( + word, + store, + self.factions.as_deref(), + self.self_store.iter().next(), + ) + }) + } + _ => false, + } + } } /// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while @@ -222,10 +253,7 @@ pub(super) fn update_hover( } // Units, players and corpses: the reference picks every CGObject in one trace and // switches on type at the end, below. - if !matches!( - net.kind, - EntityKind::Unit | EntityKind::Player | EntityKind::Corpse - ) { + if !target_state.is_candidate(net.kind, store) { continue; } // Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by @@ -324,15 +352,13 @@ pub(super) fn update_hover( if !picks_self && roots.get(parent).is_ok_and(|root| root.1) { continue; } - // The same kinds as the posed pick. A bone pile lands here: its corpse model has no + // The same candidates as the posed pick. A bone pile lands here: its corpse model has no // skeleton, so only this box test can pick it. let Ok((_, parent_net)) = units.get(parent) else { continue; }; - if !matches!( - parent_net.kind, - EntityKind::Unit | EntityKind::Player | EntityKind::Corpse - ) { + let store = roots.get(parent).ok().and_then(|root| root.6); + if !target_state.is_candidate(parent_net.kind, store) { continue; } if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) { @@ -854,10 +880,9 @@ mod tests { ); } - /// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes - /// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts. - #[test] - fn our_body_joins_the_pick_only_while_the_word_takes_it() { + /// The picker's inputs with a camera ten yards up the Z axis, looking down it through a + /// 100-pixel square with the cursor at its centre, over whatever stands at the origin. + fn pick_world() -> World { use bevy::camera::RenderTargetInfo; use bevy::window::WindowResolution; @@ -874,7 +899,7 @@ mod tests { world.init_resource::(); world.insert_resource(crate::net::SelfGuid(Some(1))); world.init_resource::(); - // Ten yards up the Z axis, looking down it through a 100-pixel square. + world.init_resource::(); let mut camera = Camera::default(); camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1); camera.computed.target_info = Some(RenderTargetInfo { @@ -892,6 +917,25 @@ mod tests { }; window.set_cursor_position(Some(Vec2::splat(50.0))); world.spawn((window, PrimaryWindow)); + world + } + + /// One drawn pick box around the origin, the box fallback's geometry for a partless body. + fn pick_box(world: &mut World, body: Entity) { + world.spawn(( + ChildOf(body), + CreaturePickPart, + Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)), + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + )); + } + + /// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes + /// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts. + #[test] + fn our_body_joins_the_pick_only_while_the_word_takes_it() { + let mut world = pick_world(); // Us, at the origin under the cursor, with one drawn pick box. let me = world .spawn(( @@ -906,13 +950,7 @@ mod tests { InheritedVisibility::VISIBLE, )) .id(); - world.spawn(( - ChildOf(me), - CreaturePickPart, - Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)), - GlobalTransform::default(), - InheritedVisibility::VISIBLE, - )); + pick_box(&mut world, me); let hover = |world: &mut World, picks: bool| { world.resource_mut::().0 = picks; world @@ -926,6 +964,76 @@ mod tests { assert_eq!(hover(&mut world, false), None, "and not once it ends"); } + /// A corpse is a candidate outside targeting (`0x5c` carries flag `0x40`); while targeting, + /// only under a word in `0x8600` and only one `0x6e6260` passes, as + /// [`crate::spell::CorpsePick`] carries the standing word to the picker. + #[test] + fn a_corpse_joins_the_pick_as_the_word_admits_it() { + use crate::spell::targeting::corpse_fixture as fx; + + let hover = |corpse: ObjectStore, word: Option| { + let mut world = pick_world(); + world.insert_resource(fx::factions()); + world.init_resource::(); + // Us, a human, away from the ray: the reaction gate's other side. + world.spawn(( + SelfPlayer, + Guid(1), + GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)), + fx::caster(1.5), + )); + let body = world + .spawn(( + Guid(fx::CORPSE), + NetEntity { + kind: EntityKind::Corpse, + display_id: None, + scale: 1.0, + }, + GlobalTransform::default(), + InheritedVisibility::VISIBLE, + corpse, + )) + .id(); + pick_box(&mut world, body); + if let Some(word) = word { + world.resource_mut::().enter( + fx::RESURRECTION, + crate::spell::CastCommit::Spell, + word, + ); + } + world + .run_system_cached(crate::spell::targeting::publish_corpse_pick) + .expect("the word publishes"); + world + .run_system_cached(update_hover) + .expect("the pick runs"); + let hovered = *world.resource::(); + assert_eq!(hovered.target, None, "a corpse is never the unit slot"); + hovered.corpse.inspect(|&e| assert_eq!(e, body)).is_some() + }; + let friend = || fx::corpse(fx::HUMAN, false); + let foe = || fx::corpse(fx::ORC, false); + let bones = || fx::corpse(fx::HUMAN, true); + + // Not targeting: every corpse, bones and foes included. + for (corpse, what) in [(friend(), "friend"), (foe(), "foe"), (bones(), "bones")] { + assert!(hover(corpse, None), "{what} outside targeting"); + } + // Resurrection's word: a friend's corpse, never a foe's or bones. + assert!(hover(friend(), Some(0x8000))); + assert!(!hover(foe(), Some(0x8000)), "a hostile corpse"); + assert!(!hover(bones(), Some(0x8000)), "bones"); + // The enemy bit takes the other side. + assert!(hover(foe(), Some(0x0200))); + assert!(!hover(friend(), Some(0x0200))); + // A heal word sets no flag `0x40`: the corpse is out of the pick, so a unit behind it + // is reachable. + assert!(!hover(friend(), Some(0x0002))); + assert!(!hover(friend(), Some(0x0040)), "a ground word"); + } + /// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe, /// which is its whole pick surface. #[test] diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 9ffe778d..9e0460e5 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -62,7 +62,7 @@ pub(crate) use price_discount::vendor_price_discount; pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR}; // The attack-with-no-target request, and the same nearest-enemy core called synchronously for the // pet bar's Attack, whose order must leave in the frame it was pressed. -pub(crate) use relations::{can_assist, can_attack, can_interact}; +pub(crate) use relations::{can_assist, can_attack, can_interact, corpse_friendly}; pub(crate) use scan::{attack_order_target, AttackNearestRequest, TargetScan}; // The chat layer's by-name asks (`/target`, `/assist`). pub(crate) use by_name::{AssistRequest, TargetByNameRequest}; diff --git a/crates/benilla-app/src/target/relations.rs b/crates/benilla-app/src/target/relations.rs index ab47a8d5..5e69e49e 100644 --- a/crates/benilla-app/src/target/relations.rs +++ b/crates/benilla-app/src/target/relations.rs @@ -8,6 +8,8 @@ //! The first two live in [`super::ring`] beside the reaction directions they turn on: //! `0x6061e0(this = player)` answers a reputation faction with the at-war bit, //! `0x6061e0(this = unit)` with the standing, and the two often disagree. +//! +//! A corpse has its own reaction gate, `0x6067d0` ([`corpse_friendly`]). use benilla_protocol::messages::{ObjectType, OwnerFallback}; @@ -84,6 +86,25 @@ pub(crate) fn is_selectable(store: Option<&ObjectStore>, self_guid: Option) || (self_guid.is_some() && store.0.unit_created_by() == self_guid) } +/// `0x6067d0(player, corpse)`, the corpse's reaction gate: `0x6064c0` compares the player's +/// template (`UNIT_FIELD_FACTIONTEMPLATE`) toward the dead player's race's (`0x5d7120`: the race +/// byte of `CORPSE_FIELD_BYTES_1`, then `ChrRaces.dbc` column 2) with the template comparator +/// `0x606640` alone, no reputation, and passes at friendly (`setge` 4). A missing row on either +/// side is neutral (`0x60651b`), so it fails, as does a missing catalog or player. +pub(crate) fn corpse_friendly( + corpse: &ObjectStore, + factions: Option<&Factions>, + self_store: Option<&ObjectStore>, +) -> bool { + use benilla_formats::Reaction; + let reaction = (|| { + let catalog = factions?.catalog(); + let own = catalog.template(self_store?.0.unit_faction_template()?)?; + Some(own.reaction_toward(catalog.race_template(corpse.0.corpse_race())?)) + })(); + reaction.unwrap_or(Reaction::Neutral) >= Reaction::Friendly +} + /// `UNIT_FLAG_PVP`, bit 12 (vmangos `UnitDefines.h:557`), what `IsPvP 0x605ff0` tests on the /// unit's owner. const UNIT_FLAG_PVP: u32 = 0x1000; @@ -128,3 +149,56 @@ pub(crate) fn can_assist( let pvp_flags = owned.as_ref().map_or(flags, |o| o.0.unit_flags()); pvp_flags & UNIT_FLAG_PVP != 0 } + +#[cfg(test)] +mod tests { + use super::*; + use crate::spell::targeting::corpse_fixture as fx; + + /// `0x6067d0`: the caster's template toward the corpse's race's, friendly or better; every gap + /// is neutral (`0x60651b`) and fails. + #[test] + fn the_corpse_gate_reads_the_race_template() { + let factions = fx::factions(); + let me = fx::caster(1.5); + let gate = |corpse: &ObjectStore| corpse_friendly(corpse, Some(&factions), Some(&me)); + assert!( + gate(&fx::corpse(fx::HUMAN, false)), + "a human toward a human" + ); + assert!(!gate(&fx::corpse(fx::ORC, false)), "a human toward an orc"); + assert!(!gate(&fx::corpse(0, false)), "race 0 has no ChrRaces row"); + assert!( + !gate(&ObjectStore(Default::default())), + "no BYTES_1 reads race 0" + ); + let human = fx::corpse(fx::HUMAN, false); + assert!(!corpse_friendly(&human, None, Some(&me)), "no catalog"); + assert!(!corpse_friendly(&human, Some(&factions), None), "no player"); + let no_template = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(35, 999)])); + assert!( + !corpse_friendly(&human, Some(&factions), Some(&no_template)), + "a template the table lacks" + ); + } + + /// The same gate on the shipped `ChrRaces.dbc` and `FactionTemplate.dbc`: a human (template 1) + /// stands friendly toward every Alliance race's corpse and not toward a Horde one's. + #[test] + fn the_corpse_gate_splits_the_sides_on_the_shipped_tables() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let factions = Factions::from_catalog( + benilla_formats::load_faction_catalog(&mut chain).expect("faction tables"), + ); + let me = fx::caster(1.5); + let gate = |race| corpse_friendly(&fx::corpse(race, false), Some(&factions), Some(&me)); + for race in [1, 3, 4, 7] { + assert!(gate(race), "Alliance race {race}"); + } + for race in [2, 5, 6, 8] { + assert!(!gate(race), "Horde race {race}"); + } + assert!(!gate(0) && !gate(10), "a race with no row"); + } +} diff --git a/crates/benilla-formats/src/factions.rs b/crates/benilla-formats/src/factions.rs index ef8f18eb..e3e1b78a 100644 --- a/crates/benilla-formats/src/factions.rs +++ b/crates/benilla-formats/src/factions.rs @@ -19,6 +19,7 @@ use crate::Chain; const FACTION_TEMPLATE: &str = "DBFilesClient\\FactionTemplate.dbc"; const FACTION: &str = "DBFilesClient\\Faction.dbc"; const FACTION_GROUP: &str = "DBFilesClient\\FactionGroup.dbc"; +const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; /// One `FactionTemplate.dbc` row: the fields the reaction comparator reads. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -204,9 +205,13 @@ impl Reaction { } } -/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps. +/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps, and each +/// `ChrRaces.dbc` race's template. pub struct FactionCatalog { templates: HashMap, + /// Race id to `ChrRaces.dbc` column 2, `FactionID`, a template id: what a corpse's reaction + /// (`0x5d7120`) and a unit's PvP team (`0x5efe00`) read for a race. + race_templates: HashMap, factions: HashMap, /// `1 << MaskID` to the localized group name, `GetZonePVPInfo`'s territory line (`0x48d540`). group_names: HashMap, @@ -220,11 +225,39 @@ pub struct FactionCatalog { } impl FactionCatalog { + /// A catalog of these template rows and race templates alone. Fixture constructor for tests; + /// the live path is [`load_faction_catalog`]. + pub fn from_rows( + templates: HashMap, + race_templates: HashMap, + ) -> Self { + Self { + templates, + race_templates, + factions: HashMap::new(), + group_names: HashMap::new(), + group_internal_names: HashMap::new(), + names: HashMap::new(), + descriptions: HashMap::new(), + } + } + /// The template row for a `UNIT_FIELD_FACTIONTEMPLATE` id. pub fn template(&self, id: u32) -> Option<&FactionTemplate> { self.templates.get(&id) } + /// Every race with a `ChrRaces.dbc` row, and its template id. + pub fn race_templates(&self) -> impl Iterator + '_ { + self.race_templates.iter().map(|(&race, &id)| (race, id)) + } + + /// A race's template row: its `ChrRaces.dbc` `FactionID`, then `FactionTemplate.dbc`. `None` + /// for a race with no row or a template that is not in the table, both the reference's null. + pub fn race_template(&self, race: u8) -> Option<&FactionTemplate> { + self.template(*self.race_templates.get(&race)?) + } + /// A `Faction.dbc` id's reputation identity, `Some` only with a reputation slot: the /// reference's `FactionHasReputation` (`0x605fc0`), tested before the comparator (`0x606530`). pub fn reputation_faction(&self, faction_id: u32) -> Option<&FactionInfo> { @@ -344,7 +377,21 @@ fn faction_group_schema() -> Schema { s } -/// Load the three faction tables off the patch chain. +/// `ChrRaces.dbc`: 29 fields in build 5875. Only the id and `FactionID` (column 2) are read; the +/// string columns are declared so the field count matches. +fn chr_races_schema() -> Schema { + let mut s = Schema::new("ChrRaces"); + for i in 0..29 { + let ty = match i { + 15 | 26 | 27 | 28 => FieldType::String, + _ => FieldType::UInt32, + }; + s.add_field(SchemaField::new(format!("f{i}"), ty)); + } + s +} + +/// Load the three faction tables and the race templates off the patch chain. pub fn load_faction_catalog(chain: &mut Chain) -> Result { let bytes = chain .read_file(FACTION_TEMPLATE) @@ -418,8 +465,19 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result { group_names.insert(1u32 << mask_id, name); } + let bytes = chain + .read_file(CHR_RACES) + .with_context(|| format!("reading {CHR_RACES}"))?; + let rs = parse(&bytes, chr_races_schema(), "ChrRaces")?; + let race_templates = rs + .records() + .iter() + .filter_map(|r| Some((u8::try_from(u32_at(r, 0)?).ok()?, u32_at(r, 2)?))) + .collect(); + Ok(FactionCatalog { templates, + race_templates, factions, group_names, group_internal_names, diff --git a/crates/benilla-formats/src/race_pvp_team.rs b/crates/benilla-formats/src/race_pvp_team.rs index d3e3614d..dc3ceb58 100644 --- a/crates/benilla-formats/src/race_pvp_team.rs +++ b/crates/benilla-formats/src/race_pvp_team.rs @@ -6,45 +6,23 @@ use std::collections::HashMap; -use anyhow::{Context, Result}; -use benilla_dbc::{FieldType, Schema, SchemaField}; +use anyhow::Result; -use crate::dbc::{parse, u32_at}; use crate::Chain; -const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; - /// Each `ChrRaces.dbc` row's team digit by race id; a race without a row (-1) is absent. pub fn load_race_pvp_teams(chain: &mut Chain) -> Result> { - let bytes = chain - .read_file(CHR_RACES) - .with_context(|| format!("reading {CHR_RACES}"))?; - // The string columns are declared only so the field count matches; none is read. - let mut schema = Schema::new("ChrRaces"); - for i in 0..29 { - let ty = match i { - 15 | 26 | 27 | 28 => FieldType::String, - _ => FieldType::UInt32, - }; - schema.add_field(SchemaField::new(format!("f{i}"), ty)); - } - let rs = parse(&bytes, schema, "ChrRaces")?; let factions = crate::load_faction_catalog(chain)?; - let mut out = HashMap::new(); - for r in rs.records() { - let (Some(race), Some(template)) = (u32_at(r, 0), u32_at(r, 2)) else { - continue; - }; - let Ok(race) = u8::try_from(race) else { - continue; - }; - // The Horde bit is tested first (`0x5efe42`), so a row with both bits reads Horde. - let team = match factions.template(template).map(|t| t.group_mask) { - Some(m) if m & 4 != 0 => 0, - Some(m) if m & 2 != 0 => 1, - _ => -1, - }; - out.insert(race, team); - } - Ok(out) + Ok(factions + .race_templates() + .map(|(race, template)| { + // The Horde bit is tested first (`0x5efe42`), so a row with both bits reads Horde. + let team = match factions.template(template).map(|t| t.group_mask) { + Some(m) if m & 4 != 0 => 0, + Some(m) if m & 2 != 0 => 1, + _ => -1, + }; + (race, team) + }) + .collect()) } diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 4e192780..e1fea725 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -461,6 +461,9 @@ pub enum UseItemTarget { /// `TARGET_FLAG_SOURCE_LOCATION` and three `f32` coords (`BindLocation`, `0x6e60f0`); only /// items carrying spell 265, such as Martin Fury, use it. vmangos reads it before the dest. Source([f32; 3]), + /// A corpse bit and the corpse's packed guid: Goblin Jumper Cables' Defibrillate + /// (`Targets 0x8000`) on a released player's corpse. + Corpse(super::spells::CorpseTarget, u64), } /// Body of `CMSG_USE_ITEM` (opcode 171): bag index (a bag's player-array slot 19-22, or @@ -487,6 +490,10 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget) body.extend_from_slice(&TARGET_FLAG_ITEM.to_le_bytes()); guid } + UseItemTarget::Corpse(corpse, guid) => { + body.extend_from_slice(&corpse.target_flag().to_le_bytes()); + guid + } UseItemTarget::Dest(dest) => { body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes()); for c in dest { diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 5cffe85e..e0814a6a 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -223,8 +223,9 @@ pub use social::{ }; pub use spellbook::SpellCooldown; pub use spells::{ - cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_gameobject, - cast_spell_item, CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart, + cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_corpse, + cast_spell_gameobject, cast_spell_item, CastOutcome, CorpseTarget, SpellCastTargets, + SpellChainTargets, SpellGo, SpellStart, }; pub use stable::{ buy_stable_slot, list_stabled_pets, stable_pet, stable_result, stable_swap_pet, unstable_pet, diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 9db976ab..181f30f6 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -304,6 +304,38 @@ pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec { body } +/// The corpse bit a corpse cast carries: `BindTarget 0x6e5b40`'s corpse arm writes `0x8000` for a +/// corpse its caster's reaction toward is friendly (`0x6e5fc7`), `0x200` for one it is not +/// (`0x6e600c`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CorpseTarget { + /// `TARGET_FLAG_CORPSE_ALLY`, a resurrection's. + Ally, + /// `TARGET_FLAG_CORPSE_ENEMY`. + Enemy, +} + +impl CorpseTarget { + /// The wire mask bit, which is also the flag-word bit the bind clears. + pub const fn target_flag(self) -> u16 { + match self { + Self::Ally => TARGET_FLAG_CORPSE_ALLY, + Self::Enemy => TARGET_FLAG_CORPSE_ENEMY, + } + } +} + +/// Body of `CMSG_CAST_SPELL` at a corpse: the corpse bit and the corpse's packed guid. The client's +/// targets writer `0x7e4e10` puts the guid for any mask bit in `0x8a02`; vmangos reads it for +/// either corpse bit (`SpellCastTargetsInfo.cpp:156-157`). +pub fn cast_spell_corpse(spell_id: u32, target: CorpseTarget, corpse_guid: u64) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&target.target_flag().to_le_bytes()); + crate::wire::write_packed_guid(corpse_guid, &mut body).expect("vec write"); + body +} + /// Body of `CMSG_CAST_SPELL` at an item, such as an enchant or poison: `TARGET_FLAG_ITEM` and the /// item's packed guid (`SpellCastTargetsInfo.cpp:159-160`). pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec { @@ -363,6 +395,28 @@ mod tests { assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]); } + /// Resurrection (2006) at a released player's corpse, `HIGHGUID_CORPSE` 0xF101 + /// (vmangos `ObjectGuid.h:76`), counter 0x2A. + #[test] + fn cast_spell_corpse_body_golden() { + const CORPSE: u64 = 0xF101_0000_0000_002A; + assert_eq!( + cast_spell_corpse(2006, CorpseTarget::Ally, CORPSE), + [ + 0xD6, 0x07, 0x00, 0x00, // spell id 2006 + 0x00, 0x80, // TARGET_FLAG_CORPSE_ALLY + 0xC1, 0x2A, 0x01, 0xF1, // packed guid: bytes 0, 6 and 7 + ], + "CMSG_CAST_SPELL (ally corpse) body" + ); + // The enemy arm differs only in the mask. + assert_eq!( + cast_spell_corpse(2006, CorpseTarget::Enemy, CORPSE), + [0xD6, 0x07, 0x00, 0x00, 0x00, 0x02, 0xC1, 0x2A, 0x01, 0xF1], + "CMSG_CAST_SPELL (enemy corpse) body" + ); + } + #[test] fn cast_spell_at_dest_body_golden() { // Spell 10 is Blizzard. 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 6d2846cd..a9d5a74c 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -437,6 +437,11 @@ impl ObjectFields { fn corpse_bytes_2(&self) -> Option { self.get_u32(33) } + /// The dead player's race, `CORPSE_FIELD_BYTES_1` byte 1, the one corpse field the reaction + /// gate reads (`0x5d7120`); 0, which no `ChrRaces` row names, when absent. + pub fn corpse_race(&self) -> u8 { + self.corpse_bytes_1().map_or(0, |b1| (b1 >> 8) as u8) + } /// The corpse's appearance, `None` in a delta that lacks either BYTES word. pub fn corpse_look(&self) -> Option { let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?); diff --git a/crates/benilla-protocol/src/world/writer/spells.rs b/crates/benilla-protocol/src/world/writer/spells.rs index 84e36c11..f7de7ae4 100644 --- a/crates/benilla-protocol/src/world/writer/spells.rs +++ b/crates/benilla-protocol/src/world/writer/spells.rs @@ -32,6 +32,20 @@ impl WorldWriter { ) } + /// `CMSG_CAST_SPELL` at a corpse: a corpse bit and the corpse's packed guid, a resurrection on + /// a released player. The server resurrects the corpse's owner (`Spell.cpp:3055-3064`). + pub fn cast_spell_corpse( + &mut self, + spell_id: u32, + target: messages::CorpseTarget, + corpse_guid: u64, + ) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell_corpse(spell_id, target, corpse_guid), + ) + } + /// `CMSG_CAST_SPELL` with `TARGET_FLAG_ITEM` and the item's packed guid: an enchant on the item /// picked in the craft frame. pub fn cast_spell_item(&mut self, spell_id: u32, item_guid: u64) -> Result<()> { diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 1927e352..b0ad68df 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -78,6 +78,19 @@ fn item_query_wire() { hx("ff18001000c3cdab50f1") ); + // A corpse target (Goblin Jumper Cables' Defibrillate, `Targets 0x8000`): the corpse bit and + // the packed guid, the block `cast_spell_corpse` writes; vmangos reads the guid for either + // corpse bit (`SpellCastTargetsInfo.cpp:156-157`). 0xF101_0000_0000_002A packs to mask 0xC1. + assert_eq!( + messages::use_item( + 255, + 24, + 0, + messages::UseItemTarget::Corpse(messages::CorpseTarget::Ally, 0xF101_0000_0000_002A) + ), + hx("ff18000080c12a01f1") + ); + // CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot. assert_eq!(messages::auto_equip_item(255, 25), hx("ff19")); From 365ff7b758106b061c40cc493e40fb14c51152a8 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:12:32 +0700 Subject: [PATCH 08/11] README: the Windows forms of the run command and the WoW link, since the POSIX VAR=value prefix does not run in cmd or PowerShell (#466) Fixes #465 Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 661e2898..63ce4a66 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,19 @@ benilla builds and runs on macOS, Linux and Windows. You need: WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla ``` +On Windows, in PowerShell: + +```powershell +$env:WOW_DATA="C:\path\to\WoW\Data"; cargo run --release -p benilla +``` + `WOW_DATA` names the install's `Data` folder; a link to the install named `WoW` at the repo root -does the same. The server defaults to `localhost:3724`, the stock auth port. Point `WOW_HOST` at -another (`WOW_HOST=play.example.com`, or `play.example.com:5000` for a remapped port), or set it -from the Realmlist button on the login screen, which remembers it. Credentials go in at the login -screen, or set `WOW_USER` and `WOW_PASS` to skip it. +does the same (`ln -s /path/to/WoW WoW`, or on Windows a junction, which needs no admin rights: +`New-Item -ItemType Junction -Path WoW -Target C:\path\to\WoW`). The server defaults to +`localhost:3724`, the stock auth port. Point `WOW_HOST` at another (`WOW_HOST=play.example.com`, or +`play.example.com:5000` for a remapped port), or set it from the Realmlist button on the login +screen, which remembers it. Credentials go in at the login screen, or set `WOW_USER` and `WOW_PASS` +to skip it. Settings, screenshots and addons live in `benilla-config/` at the repo root: a 1.12 addon goes in `benilla-config/AddOns/`. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) has the rest, from the From 980378b6968f90a9a484b4efba232434948b891f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:27:13 +0700 Subject: [PATCH 09/11] With realms in more than one category, the realm list shows the stock category tabs, so every realm can be reached (#467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With realms in more than one category, the realm list shows the stock category tabs, so every realm can be reached. Before, it listed only the first category's realms and had no way to the rest. - **The Region** is `Wow.ini`'s `[WoW Config] Region`, read through the archive chain as `0x40249d` loads it (the stock install ships it in `interface.MPQ`), with the section and key compared case-insensitively (`0x445356`, `0x4453cb`) and the value parsed as `0x445470` does. It is 0 when the file or the key is missing. - **The categories** are the `Cfg_Categories.dbc` rows whose Region column equals it, in file order (`0x46e300`): id from column 0, name from the row. - **Placement** (`0x46e510`): a realm's category byte 0 takes the first category's id (`0x46e5b8`), then joins every category with that id (`0x46e60b`). A byte matching no category puts the realm on no tab, as in 1.12.1. Tab ordinals count the non-empty categories only (`0x46edf0`), and the load-band statistics still run over every realm. - **The tabs** are built from `RealmList.xml`: hidden for one category, `RealmListTab1`..`N` with the category names for two to eight, the front one raised and disabled. A click fronts the tab and re-lists (`RealmListTab_OnClick`), the highlight falls back to the current realm or none, and the scroll clamps to the new tab. Change Realm and `OnShow` keep the stored category (`0x46f188`, `0x46f400`). - **No Region rows**: every realm lists on one untabbed page. The reference reads a NULL category there (`0x46f26a`, `0x46e5b8`), and benilla does not copy the fault. - **Logging in** by name, the remembered or current realm, still searches every realm, as the reference resolves by name. - A dev capture, `WOW_CAPTURE=glue-realmlist`, shows two tabs over five invented realms. Checked: tests for the Region read, the category rows for a US and an EU Region from the install's DBC, placement (byte 0 folded, an unmatched byte on no tab), the strip hidden for one category and named for two, a tab click through the real click path, the highlight and scroll on a switch, the stored category across Change Realm, the statistics over every realm, and the no-rows fallback. Each fails with its piece undone. Gates and the smoke run green on macOS; no platform seam touched. Fixes #362 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/capture/mod.rs | 4 +- crates/benilla-app/src/capture/realm_list.rs | 57 +++ crates/benilla-app/src/capture/scenarios.rs | 10 +- crates/benilla-app/src/glue/art.rs | 25 +- .../benilla-app/src/realm_select/category.rs | 368 +++++++++++++++++ crates/benilla-app/src/realm_select/input.rs | 8 +- crates/benilla-app/src/realm_select/mod.rs | 142 ++++--- crates/benilla-app/src/realm_select/screen.rs | 384 +++++++++++++++++- crates/benilla-formats/src/cfg_categories.rs | 96 +++++ crates/benilla-formats/src/lib.rs | 4 + crates/benilla-formats/src/wow_ini.rs | 107 +++++ docs/MAP.md | 2 +- 12 files changed, 1126 insertions(+), 81 deletions(-) create mode 100644 crates/benilla-app/src/capture/realm_list.rs create mode 100644 crates/benilla-app/src/realm_select/category.rs create mode 100644 crates/benilla-formats/src/cfg_categories.rs create mode 100644 crates/benilla-formats/src/wow_ini.rs diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index c2a8a0bd..07a4da74 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -76,6 +76,7 @@ mod probe_stone; mod probe_taxi; mod probe_vendor_swap; mod probes; +mod realm_list; mod scenarios; use crate::run_mode::CaptureMode; pub(crate) use depth_probe::DepthProbePlugin; @@ -138,7 +139,7 @@ pub(crate) use probe_pitch::ProbePitchPlugin; pub(crate) fn start_state() -> crate::char_select::ClientState { match glue_screen() { Some(GlueScreen::CharCreate) => crate::char_select::ClientState::CharCreate, - Some(GlueScreen::Login) => crate::char_select::ClientState::Login, + Some(GlueScreen::Login | GlueScreen::RealmList) => crate::char_select::ClientState::Login, None if crate::run_mode::scenario_active() => crate::char_select::ClientState::InWorld, None => crate::char_select::ClientState::Login, } @@ -673,6 +674,7 @@ impl Plugin for CapturePlugin { // Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier // frames, so the same frame's feeds and the one-shot `MERCHANT_SHOW` paint must see it. .add_systems(Update, seed_ui_fixture.before(crate::ui_unit::UnitFeed)) + .add_systems(Update, realm_list::seed_realm_list) .add_systems(Last, drive_capture); } } diff --git a/crates/benilla-app/src/capture/realm_list.rs b/crates/benilla-app/src/capture/realm_list.rs new file mode 100644 index 00000000..270314f4 --- /dev/null +++ b/crates/benilla-app/src/capture/realm_list.rs @@ -0,0 +1,57 @@ +//! `glue-realmlist`: the realm list over the login screen with realms in two categories, which the +//! live server cannot show (it lists one). The list is published as the realm park publishes one, +//! then the dialog is raised as Change Realm raises it; the tabs are named from the install's own +//! Region, so bytes 1 and 2 are "English" and "German" under Region 3 and 1 alone under Region 1. + +use bevy::prelude::*; + +use benilla_protocol::RealmInfo; + +use super::CaptureCtx; + +/// The scenario's name in [`super::scenarios::GLUE_SCENARIOS`]. +pub(super) const NAME: &str = "glue-realmlist"; + +/// Invented realms: three on category byte 1, two on byte 2, a mix of types, loads and counts. +fn fixture() -> Vec { + let realm = + |name: &str, category: u8, realm_type: u32, population: f32, characters: u8| RealmInfo { + name: name.into(), + address: "127.0.0.1:8085".into(), + population, + characters, + realm_type, + flags: 0, + category, + id: 0, + }; + vec![ + realm("Amberfield", 1, 0, 0.5, 2), + realm("Brightwater", 1, 1, 1.5, 0), + realm("Copperdale", 1, 6, 1.0, 0), + realm("Dunmoor", 2, 1, 0.8, 0), + realm("Eisenwald", 2, 0, 1.2, 0), + ] +} + +/// Publish the list, let the policy take it, then raise the dialog. +pub(super) fn seed_realm_list( + ctx: Res, + mut step: Local, + mut lists: MessageWriter, + mut realms: ResMut, + choice: Res, +) { + if ctx.name != NAME { + return; + } + match *step { + 0 => { + lists.write(crate::net::RealmListMessage { realms: fixture() }); + } + 2 => crate::realm_select::open_over_char_select(&mut realms, &choice), + 3.. => return, + _ => {} + } + *step += 1; +} diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 9df0a96c..e8b2290b 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -167,10 +167,13 @@ pub(crate) enum GlueScreen { /// The login screen, `UI_MainMenu` behind the account form. Its backdrop is the narrowest of /// the seven scenes, so a wide window (`WOW_WIN=2560x1440`) reaches its edges first. Login, + /// The realm list over the login screen, fed a list in two categories + /// (`super::realm_list`). + RealmList, } /// The glue scenarios: character creation as a human male warrior, the race the reference's own -/// screenshots use, and the login screen. +/// screenshots use, the login screen, and the realm list's category tabs over it. pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[ GlueScenario { name: "glue-charcreate", @@ -182,6 +185,11 @@ pub(super) const GLUE_SCENARIOS: &[GlueScenario] = &[ screen: GlueScreen::Login, pick: None, }, + GlueScenario { + name: super::realm_list::NAME, + screen: GlueScreen::RealmList, + pick: None, + }, ]; /// The golden baseline: two spots at noon and at night, Elwynn water and a Felwood hollow on diff --git a/crates/benilla-app/src/glue/art.rs b/crates/benilla-app/src/glue/art.rs index 8db69f07..8010d921 100644 --- a/crates/benilla-app/src/glue/art.rs +++ b/crates/benilla-app/src/glue/art.rs @@ -144,6 +144,10 @@ pub(crate) struct GlueArt { pub(crate) column_tabs: Option<(Handle, Vec2)>, pub(crate) tab_highlight: Option>, pub(crate) sort_arrow: Option<(Handle, Vec2)>, + /// The realm list's category tabs (`RealmListTabButtonTemplate`): the front tab's + /// `UI-Character-ActiveTab` and the others' `UI-Character-InActiveTab`. + pub(crate) tab_active: Option<(Handle, Vec2)>, + pub(crate) tab_inactive: Option<(Handle, Vec2)>, } /// `RealmSortButtonTemplate`'s `WhoFrame-ColumnTabs` slices, `[left, right, top, bottom]` each: @@ -155,6 +159,13 @@ pub(crate) const COLUMN_TAB_TC: [[f32; 4]; 3] = [ ]; /// `$parentArrow`'s texcoords into `UI-SortArrow`, the down-pointing half. pub(crate) const SORT_ARROW_TC: [f32; 4] = [0.0, 0.5625, 0.0, 1.0]; +/// `RealmListTabButtonTemplate`'s `UI-Character-(In)ActiveTab` slices, `[left, right, top, +/// bottom]` each: the left end (20 wide), the middle stretched to the label, the right end. +pub(crate) const REALM_TAB_TC: [[f32; 4]; 3] = [ + [0.0, 0.156_25, 0.0, 1.0], + [0.156_25, 0.843_75, 0.0, 1.0], + [0.843_75, 1.0, 0.0, 1.0], +]; /// The `HelpFrame-*` plate: six pieces tiling a 640×512 panel (TopLeft/Top 256², TopRight /// 128×256 on top; BotLeft/Bottom/BotRight below). @@ -427,10 +438,20 @@ impl GlueArt { add_mats, ); self.sort_arrow = sized(assets, "Interface\\Buttons\\UI-SortArrow", images); + self.tab_active = sized( + assets, + "Interface\\PaperDollInfoFrame\\UI-Character-ActiveTab", + images, + ); + self.tab_inactive = sized( + assets, + "Interface\\PaperDollInfoFrame\\UI-Character-InActiveTab", + images, + ); debug!( "glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \ questhl {} titlehl {} greycheck {} tipborder {} scrolltrack {} \ - coltabs {} tabhl {} sortarrow {}", + coltabs {} tabhl {} sortarrow {} realmtabs {}/{}", self.help_frame.is_some(), self.dialog_header.is_some(), self.close_btn.is_some(), @@ -444,6 +465,8 @@ impl GlueArt { self.column_tabs.is_some(), self.tab_highlight.is_some(), self.sort_arrow.is_some(), + self.tab_active.is_some(), + self.tab_inactive.is_some(), ); debug!( "glue art: races {} classes {} gender {} factions {} banners {} hilight {} logo {} \ diff --git a/crates/benilla-app/src/realm_select/category.rs b/crates/benilla-app/src/realm_select/category.rs new file mode 100644 index 00000000..83b81fb6 --- /dev/null +++ b/crates/benilla-app/src/realm_select/category.rs @@ -0,0 +1,368 @@ +//! The realm categories behind the list's tabs. +//! +//! The category set is the client Region's `Cfg_Categories.dbc` rows, built once per process +//! (`0x46e300`). Each list rebuild (`0x46e510`) places every realm by its wire category byte: a 0 +//! byte takes the first category's id (`0x46e5b8`), then the byte joins every category whose id +//! equals it (`0x46e60b`), and a byte no category carries leaves the realm on no tab at all. +//! +//! A category the Lua names crosses as a 1-based ordinal over the non-empty categories only; the +//! map to the stored index (`0x46edf0`) has no sentinel, so an ordinal past the last one reads +//! the first stored category, empty or not. + +use bevy::prelude::*; + +use benilla_formats::RealmCategory; +use benilla_protocol::RealmInfo; + +use super::Realms; + +/// `MAX_REALM_CATEGORY_TABS` (`RealmList.lua:4`). +pub(super) const MAX_TABS: usize = 8; + +/// Every category's realms, as indices into the list in wire order. Before the category set is +/// read, or when the Region has none, there is one category holding every realm. +fn place(categories: Option<&[RealmCategory]>, realms: &[RealmInfo]) -> Vec> { + // Deviation: no Region rows faults the reference on a NULL category (`0x46e5b8`); list all. + let Some(categories) = categories.filter(|c| !c.is_empty()) else { + return vec![(0..realms.len()).collect()]; + }; + let mut buckets = vec![Vec::new(); categories.len()]; + for (i, realm) in realms.iter().enumerate() { + let byte = match realm.category { + 0 => match categories.first() { + Some(first) => first.id, + None => continue, + }, + b => u32::from(b), + }; + // No break: a realm joins every category carrying its id. + for (bucket, category) in buckets.iter_mut().zip(categories) { + if category.id == byte { + bucket.push(i); + } + } + } + buckets +} + +/// `0x46edf0`: the stored index of the `visible`-th non-empty category (1-based), or 0. +fn stored_index(buckets: &[Vec], visible: usize) -> usize { + buckets + .iter() + .enumerate() + .filter(|(_, b)| !b.is_empty()) + .nth(visible.wrapping_sub(1)) + .map_or(0, |(i, _)| i) +} + +impl Realms { + /// Every category's realms; see [`place`]. + fn buckets(&self) -> Vec> { + place(self.categories.as_deref(), &self.realms) + } + + /// The realms of the tab in front, unsorted: `GetNumRealms` and `GetRealmInfo` with + /// `RealmList.selectedCategory`. + pub(super) fn category_realms(&self) -> Vec { + let mut buckets = self.buckets(); + let at = stored_index(&buckets, self.category()); + if at < buckets.len() { + std::mem::take(&mut buckets[at]) + } else { + Vec::new() + } + } + + /// `RealmList.selectedCategory`: nil until the first `RealmListUpdate` makes it 1. + pub(super) fn category(&self) -> usize { + self.category.unwrap_or(1) + } + + /// The tab labels, `GetRealmCategories` (`0x46f1f0`): the names of the categories holding a + /// realm. `RealmList_UpdateTabs` hides the strip unless there are two or more, so the one name + /// the reference still pushes when none hold a realm (`0x46f255`) is left out. + pub(super) fn tabs(&self) -> Vec<&str> { + let Some(categories) = &self.categories else { + return Vec::new(); + }; + self.buckets() + .iter() + .zip(categories) + .filter(|(b, _)| !b.is_empty()) + .map(|(_, c)| c.name.as_str()) + .collect() + } + + /// `GetSelectedCategory` (`0x46f400`): the ordinal of the first non-empty category at or after + /// the stored one, else 1. + pub(super) fn selected_category(&self) -> usize { + let from = self.chosen_category.unwrap_or(0); + self.buckets() + .iter() + .enumerate() + .filter(|(_, b)| !b.is_empty()) + .enumerate() + .find(|(_, (i, _))| *i >= from) + .map_or(1, |(ordinal, _)| ordinal + 1) + } + + /// `RealmList_OnShow`: the tab `GetSelectedCategory` names comes to the front, when a tab + /// button carries that ordinal. + pub(super) fn front_selected_category(&mut self) { + let ordinal = self.selected_category(); + if ordinal <= MAX_TABS { + self.category = Some(ordinal); + } + } + + /// `RealmListTab_OnClick`: the tab comes to the front and the list redraws. The redraw has no + /// clicked name to keep (`RealmListUpdate` clears `selectedName`), so the highlight falls back + /// to the realm this session is on, when that realm is on the new tab. + pub(super) fn click_tab(&mut self, ordinal: usize) { + self.category = Some(ordinal); + self.refresh_in = super::REFRESH_SECS; + self.highlight_current(); + self.clamp_offset(); + } + + /// `ChangeRealm` stores the front tab's category (`0x46f188`), unconditionally, before it + /// dials; `GetSelectedCategory` reads it when the list next opens. + pub(super) fn store_front_category(&mut self) { + self.chosen_category = Some(stored_index(&self.buckets(), self.category())); + } + + /// The rebuild's tail (`0x46e6f0`): the category holding the realm this session names becomes + /// the stored one; with no such realm, or one on no tab, it is left as it was. + pub(super) fn store_current_category(&mut self) { + let (Some(categories), Some(want)) = (&self.categories, &self.current) else { + return; + }; + // The walk visits every realm, so the last one of the name decides. + let Some(realm) = self + .realms + .iter() + .rfind(|r| r.name.eq_ignore_ascii_case(want)) + else { + return; + }; + let byte = match realm.category { + 0 => match categories.first() { + Some(first) => first.id, + None => return, + }, + b => u32::from(b), + }; + if let Some(at) = categories.iter().position(|c| c.id == byte) { + self.chosen_category = Some(at); + } + } +} + +/// Read the client Region's category set once, after the archive chain opens. +pub(super) fn load_categories( + mut realms: ResMut, + assets: Option>, +) { + use benilla_assets::LockRecover; + let Some(assets) = assets else { + return; + }; + let mut chain = assets.chain.lock_recover(); + let region = benilla_formats::client_region(&chain); + match benilla_formats::load_realm_categories(&mut chain, region) { + Ok(categories) => { + info!( + "realm list: Region {region}, categories {:?}", + categories + .iter() + .map(|c| (c.id, c.name.as_str())) + .collect::>() + ); + if categories.is_empty() { + warn!( + "realm list: Region {region} names no Cfg_Categories.dbc row, so every realm \ + lists on one untabbed page (the reference's Region is Wow.ini's [WoW Config] \ + Region)" + ); + } + realms.categories = Some(categories); + } + Err(e) => warn!( + "realm list: Cfg_Categories.dbc failed to load, so every realm lists on one untabbed \ + page: {e:#}" + ), + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + fn cat(id: u32, name: &str) -> RealmCategory { + RealmCategory { + id, + name: name.into(), + } + } + + /// Region 1's rows, `Cfg_Categories.dbc` in file order. + pub(in crate::realm_select) fn us() -> Vec { + vec![cat(1, "United States"), cat(5, "Oceanic")] + } + + fn realm(name: &str, category: u8) -> RealmInfo { + RealmInfo { + name: name.into(), + address: "127.0.0.1:8085".into(), + population: 1.0, + characters: 0, + realm_type: 0, + flags: 0, + category, + id: 0, + } + } + + fn realms(list: &[(&str, u8)]) -> Realms { + Realms { + realms: list.iter().map(|&(n, c)| realm(n, c)).collect(), + categories: Some(us()), + ..Realms::default() + } + } + + fn names(r: &Realms) -> Vec { + r.rows().iter().map(|&i| r.realms[i].name.clone()).collect() + } + + /// The US client reads its two categories from the install's own `Cfg_Categories.dbc`. + #[test] + fn the_us_categories_come_from_the_install() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let loaded = benilla_formats::load_realm_categories(&mut chain, 1).expect("DBC"); + assert_eq!(loaded, us()); + } + + /// A category byte is an id, matched for equality: 1 and 5 are the two US tabs. + #[test] + fn a_realm_lands_on_the_tab_whose_id_is_its_category_byte() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5), ("Beta", 1)]); + assert_eq!(r.tabs(), ["United States", "Oceanic"]); + assert_eq!(names(&r), ["Alpha", "Beta"]); + r.category = Some(2); + assert_eq!(names(&r), ["Down Under"]); + } + + /// Byte 0 (a vmangos development-zone realm) folds into the first category. + #[test] + fn a_zero_byte_joins_the_first_category() { + let r = realms(&[("Dev", 0), ("Alpha", 1)]); + assert_eq!(r.tabs(), ["United States"], "one tab: the strip hides"); + assert_eq!(names(&r), ["Alpha", "Dev"]); + } + + /// A byte no category of the Region carries lists nowhere. + #[test] + fn a_byte_outside_the_region_is_on_no_tab() { + let r = realms(&[("Alpha", 1), ("Berlin", 2)]); + assert_eq!(r.tabs(), ["United States"]); + assert_eq!(names(&r), ["Alpha"]); + assert_eq!(r.category_realms(), [0]); + } + + /// Only a category holding a realm gets a tab, and ordinals count those alone. + #[test] + fn an_empty_category_has_no_tab_and_no_ordinal() { + let mut r = realms(&[("Down Under", 5)]); + assert_eq!(r.tabs(), ["Oceanic"], "United States holds nothing"); + assert_eq!( + names(&r), + ["Down Under"], + "ordinal 1 is the Oceanic category" + ); + // Past the last ordinal the map reads stored category 0, the empty United States. + r.category = Some(2); + assert!(names(&r).is_empty()); + } + + /// Before the category set is read every realm lists, untabbed. + #[test] + fn with_no_category_set_every_realm_lists_untabbed() { + let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]); + r.categories = None; + assert!(r.tabs().is_empty()); + assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]); + } + + /// A Region no row names (Region 0, no `Wow.ini` Region) lists every realm untabbed too, + /// where the reference faults; the list opens on it and a realm on it can be entered. + #[test] + fn a_region_with_no_categories_lists_every_realm_untabbed() { + let mut r = realms(&[("Alpha", 1), ("Berlin", 2), ("Dev", 0)]); + r.categories = Some(Vec::new()); + assert!(r.tabs().is_empty()); + assert_eq!(names(&r), ["Alpha", "Berlin", "Dev"]); + r.current = Some("Berlin".into()); + r.store_current_category(); + r.open(); + assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Berlin")); + assert!(r.can_enter()); + } + + /// A tab click fronts its category, and the highlight moves to the session's realm if it is + /// there, else nowhere, so Okay cannot enter a realm from another tab. + #[test] + fn a_tab_click_relists_and_resets_the_highlight() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + r.current = Some("Alpha".into()); + r.select("Alpha"); + r.click_tab(2); + assert_eq!(names(&r), ["Down Under"]); + assert!(r.selected().is_none() && !r.can_enter()); + r.click_tab(1); + assert_eq!(names(&r), ["Alpha"]); + assert_eq!(r.selected().map(|r| r.name.as_str()), Some("Alpha")); + } + + /// A tab with fewer realms than the scroll offset shows from its top. + #[test] + fn a_tab_click_clamps_the_scroll_to_the_new_tab() { + let mut list: Vec<(String, u8)> = (0..30).map(|i| (format!("R{i:02}"), 1)).collect(); + list.push(("Down Under".into(), 5)); + let refs: Vec<(&str, u8)> = list.iter().map(|(n, c)| (n.as_str(), *c)).collect(); + let mut r = realms(&refs); + r.offset = 12; + r.click_tab(2); + assert_eq!(r.offset, 0); + } + + /// The list opens on the tab of the realm this session is on, and a realm entered from a tab + /// brings that tab back next time. + #[test] + fn the_list_opens_on_the_current_realms_tab() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + assert_eq!(r.selected_category(), 1, "nothing stored: the first tab"); + r.current = Some("down under".into()); + r.store_current_category(); + assert_eq!(r.selected_category(), 2); + r.front_selected_category(); + assert_eq!(names(&r), ["Down Under"]); + + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + r.category = Some(2); + r.store_front_category(); + r.category = Some(1); + r.front_selected_category(); + assert_eq!(r.category(), 2, "ChangeRealm's stored tab"); + } + + /// A stored category that has emptied gives the next non-empty one's ordinal. + #[test] + fn a_stored_category_that_emptied_reads_the_next_tab_on() { + let mut r = realms(&[("Alpha", 1), ("Down Under", 5)]); + r.chosen_category = Some(0); + r.realms.remove(0); + assert_eq!(r.selected_category(), 1, "Oceanic, now the only tab"); + } +} diff --git a/crates/benilla-app/src/realm_select/input.rs b/crates/benilla-app/src/realm_select/input.rs index 9b8dee46..eb49770c 100644 --- a/crates/benilla-app/src/realm_select/input.rs +++ b/crates/benilla-app/src/realm_select/input.rs @@ -15,8 +15,8 @@ use super::{is_down, Realms}; /// The double-click window, the interval the character select screen uses. const DOUBLE_CLICK_SECS: f32 = 0.4; -/// Clicks: a row selects (a second one enters), the column headers sort, Okay enters, Cancel and -/// the close X leave. +/// Clicks: a row selects (a second one enters), a tab fronts its category, the column headers +/// sort, Okay enters, Cancel and the close X leave. pub(super) fn clicks( buttons: Query<(Entity, &RealmAction)>, hits: Res, @@ -49,6 +49,9 @@ pub(super) fn clicks( enter = true; } } + // The front tab is `Disable()`d (`GlueTemplates_SelectTab`) and takes no click. + RealmAction::Tab(ordinal) if ordinal != realms.category() => realms.click_tab(ordinal), + RealmAction::Tab(_) => {} RealmAction::Ok => enter = true, RealmAction::Cancel => leave = Some(true), RealmAction::Close => leave = Some(false), @@ -177,6 +180,7 @@ fn try_enter(realms: &mut Realms, choice: &RealmChoice, sounds: &mut MessageWrit let name = realm.name.clone(); sounds.write(GlueSound("gsLoginChangeRealmOK")); realms.hide(); + realms.store_front_category(); realms.enter(choice, name); } diff --git a/crates/benilla-app/src/realm_select/mod.rs b/crates/benilla-app/src/realm_select/mod.rs index 9c9630a9..467b268c 100644 --- a/crates/benilla-app/src/realm_select/mod.rs +++ b/crates/benilla-app/src/realm_select/mod.rs @@ -19,6 +19,7 @@ //! names no screen; only `ConnectToRealm` (`0x46b210`) drops the world connection, once a realm is //! chosen. So the character park serves the list in place and Cancel there does nothing. +mod category; mod input; mod load; mod screen; @@ -29,6 +30,7 @@ pub(crate) use load::pvp_rp; use bevy::prelude::*; use crate::net::{RealmChoice, RealmListMessage, RealmRequest}; +use benilla_formats::RealmCategory; use benilla_protocol::RealmInfo; /// The persisted 1.12 CVar naming the last realm connected to (`0x83f2d0`); the SavedVariables @@ -46,8 +48,15 @@ pub(crate) struct Realms { pub(super) realms: Vec, /// The highlighted row by realm name, so a refresh that adds or drops a realm cannot move it. selected: Option, - /// The category tab in front (`RealmList.selectedCategory`), by the wire's category byte. - pub(super) category: Option, + /// The client Region's categories ([`category`]), read once at startup; `None` until then, + /// and every realm lists untabbed. + pub(super) categories: Option>, + /// The tab in front, `RealmList.selectedCategory`: a 1-based ordinal over the categories + /// holding a realm. + pub(super) category: Option, + /// `[0x837e98]`, the stored category `GetSelectedCategory` opens the list on, by index into + /// [`Self::categories`]; `None` is its `-1` load value. + chosen_category: Option, /// First visible row within the selected category (`RealmList.offset`). pub(super) offset: usize, /// Which column the list is sorted on, and which way. @@ -117,17 +126,11 @@ impl Sort { } impl Realms { - /// The rows the screen draws: indices into [`Self::realms`] for the selected category, sorted. + /// The rows the screen draws: indices into [`Self::realms`] for the tab in front, sorted per + /// category (`0x46e750`). pub(super) fn rows(&self) -> Vec { - let category = self.category; let (mean, stddev) = self.stats(); - let mut rows: Vec = self - .realms - .iter() - .enumerate() - .filter(|(_, r)| category.is_none_or(|c| r.category == c)) - .map(|(i, _)| i) - .collect(); + let mut rows = self.category_realms(); rows.sort_by(|&a, &b| { let (ra, rb) = (&self.realms[a], &self.realms[b]); for (key, descending) in self.sort.0 { @@ -153,15 +156,6 @@ impl Realms { rows } - /// The category bytes present, ascending, one tab each; `RealmList_UpdateTabs` hides the strip - /// when there is only one. - pub(super) fn categories(&self) -> Vec { - let mut cats: Vec = self.realms.iter().map(|r| r.category).collect(); - cats.sort_unstable(); - cats.dedup(); - cats - } - /// The load distribution, over every realm, not the selected category: `0x46e510` walks the /// flat all-categories array, so switching tabs moves no band. pub(super) fn stats(&self) -> (f32, f32) { @@ -186,22 +180,35 @@ impl Realms { self.selected().is_some_and(|r| !is_down(r)) } - /// `RealmList:Show()`: reset the scroll, highlight [`Self::current`], start the refresh timer. + /// `RealmList:Show()`: front the stored category's tab, reset the scroll, highlight + /// [`Self::current`], start the refresh timer. fn open(&mut self) { self.shown = true; + self.front_selected_category(); self.offset = 0; - self.selected = self - .current - .as_deref() - .and_then(|want| { - self.realms - .iter() - .find(|r| r.name.eq_ignore_ascii_case(want)) - }) - .map(|r| r.name.clone()); + self.highlight_current(); self.refresh_in = REFRESH_SECS; } + /// `RealmListUpdate` with no clicked name: the highlight is the row `GetRealmInfo` flags + /// `currentRealm`, [`Self::current`] case-folded, on the tab in front, or none. + fn highlight_current(&mut self) { + self.selected = self.current.as_deref().and_then(|want| { + self.rows() + .into_iter() + .map(|i| &self.realms[i]) + .find(|r| r.name.eq_ignore_ascii_case(want)) + .map(|r| r.name.clone()) + }); + } + + /// `GlueScrollFrame_Update` clamps the scroll bar to the tab's row count, and the bar's + /// change handler writes `RealmList.offset` back. + fn clamp_offset(&mut self) { + let max = self.rows().len().saturating_sub(screen::MAX_ROWS); + self.offset = self.offset.min(max); + } + /// Answer a park with a realm and remember it as [`Self::current`]. pub(super) fn enter(&mut self, choice: &RealmChoice, name: String) { self.current = Some(name.clone()); @@ -230,27 +237,32 @@ pub(crate) struct RealmSelectPlugin; impl Plugin for RealmSelectPlugin { fn build(&self, app: &mut App) { - app.init_resource::().add_systems( - Update, - ( - apply_realm_policy, - // Ungated by state: the dialog stands over login and character select alike. - screen::drive_screen, - // Input before the row refresh, so a click shows on the frame it landed. - smoke::debug_realm_smoke, + app.init_resource::() + .add_systems( + Startup, + category::load_categories.after(benilla_assets::AssetSet::Open), + ) + .add_systems( + Update, ( - input::clicks, - input::keys, - tick_refresh, - screen::refresh_rows, + apply_realm_policy, + // Ungated by state: the dialog stands over login and character select alike. + screen::drive_screen, + // Input before the row refresh, so a click shows on the frame it landed. + smoke::debug_realm_smoke, + ( + input::clicks, + input::keys, + tick_refresh, + screen::refresh_list, + ) + .chain() + .run_if(|realms: Res| realms.shown), ) .chain() - .run_if(|realms: Res| realms.shown), - ) - .chain() - .after(benilla_world::schedule::WorldStage::Net) - .before(crate::glue::GlueVisuals), - ); + .after(benilla_world::schedule::WorldStage::Net) + .before(crate::glue::GlueVisuals), + ); } } @@ -268,16 +280,6 @@ fn apply_realm_policy( for msg in msgs.read() { realms.realms = msg.realms.clone(); realms.refresh_in = REFRESH_SECS; - // Keep the tab across a refresh while it still exists, else the first. - let cats = realms.categories(); - if !realms.category.is_some_and(|c| cats.contains(&c)) { - realms.category = cats.first().copied(); - } - - // A list published while the dialog is up is a refresh, never a question. - if realms.shown { - continue; - } // The registered default is empty: a client that has never connected. let remembered = cvars .get(CVAR_REALM_NAME) @@ -286,6 +288,14 @@ fn apply_realm_policy( if realms.current.is_none() { realms.current = remembered.clone(); } + // The rebuild (`0x46e510`): the tab in front is kept as its ordinal, as the Lua keeps it. + realms.store_current_category(); + realms.clamp_offset(); + + // A list published while the dialog is up is a refresh, never a question. + if realms.shown { + continue; + } match auto_answer( &realms, remembered.as_deref(), @@ -383,11 +393,11 @@ mod tests { r.rows().iter().map(|&i| r.realms[i].name.clone()).collect() } + /// A list under the US client's categories. fn list(realms: Vec) -> Realms { - let category = realms.first().map(|r| r.category); Realms { realms, - category, + categories: Some(category::tests::us()), ..Realms::default() } } @@ -407,13 +417,15 @@ mod tests { assert!(!is_down(&sentinel) && !is_invalid(&sentinel)); } - /// The load distribution spans every realm, so switching tabs relabels no band. + /// The load distribution spans every realm, a realm on no tab included, so switching tabs + /// relabels no band. #[test] fn a_category_tab_scopes_the_rows_but_not_the_load_distribution() { let mut r = list(vec![ realm("Alpha", 1, 0, 0, 0, 1.0), realm("Beta", 1, 0, 0, 0, 1.0), - realm("Gamma", 2, 0, 0, 0, 400.0), + realm("Gamma", 5, 0, 0, 0, 400.0), + realm("Berlin", 2, 0, 0, 0, 7.0), ]); r.category = Some(1); assert_eq!(r.rows().len(), 2, "the tab scopes the rows"); @@ -426,8 +438,8 @@ mod tests { mean_on_tab_one, mean_on_tab_two, "the distribution is global — the tab must not move it" ); - // (1 + 1 + 400) / 3. - assert!((mean_on_tab_one - 134.0).abs() < 1e-3, "{mean_on_tab_one}"); + // (1 + 1 + 400 + 7) / 4: the category-2 realm is on no US tab and still counts. + assert!((mean_on_tab_one - 102.25).abs() < 1e-3, "{mean_on_tab_one}"); } /// The reference hides the tab strip for a single category. @@ -437,7 +449,7 @@ mod tests { realm("Alpha", 1, 0, 0, 0, 1.0), realm("Beta", 1, 0, 0, 0, 1.0), ]); - assert_eq!(r.categories(), vec![1]); + assert_eq!(r.tabs(), ["United States"]); } /// The default order is characters, load, name, mode, all ascending; more characters first. diff --git a/crates/benilla-app/src/realm_select/screen.rs b/crates/benilla-app/src/realm_select/screen.rs index ad980ccc..cbd6710e 100644 --- a/crates/benilla-app/src/realm_select/screen.rs +++ b/crates/benilla-app/src/realm_select/screen.rs @@ -1,21 +1,24 @@ //! The realm list's layout: `GlueXML/RealmList.xml` in Bevy UI, on the 1024×768 glue scale. //! //! A full-screen dim, then the 640×512 `HelpFrame` plate (the AddOns list's) with the title plate, -//! four sort headers, eighteen rows, the selection band, the close X, and Okay and Cancel. Rows are -//! spawned empty and [`refresh_rows`] writes them every frame from [`super::Realms`]. +//! four sort headers, the eight category tabs under the plate, eighteen rows, the selection band, +//! the close X, and Okay and Cancel. Rows and tabs are spawned empty and [`refresh_list`] writes +//! them every frame from [`super::Realms`]. //! -//! The category tab strip is not built: `RealmList_UpdateTabs` hides it for a single category, and -//! with several, only the first category's realms show. +//! The tab strip shows only with two or more categories holding a realm (`RealmList_UpdateTabs`), +//! one tab per category, and the rows are the front tab's. +use bevy::ecs::system::SystemParam; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, SORT_ARROW_TC}; +use crate::glue::art::{tc_rect, GlueArt, COLUMN_TAB_TC, GOLD, REALM_TAB_TC, SORT_ARROW_TC}; use crate::glue::widgets::{ - abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight, + abs, glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueDisabled, GlueText, Hilight, }; use crate::glue_strings::GlueStrings; +use super::category::MAX_TABS; use super::load; use super::{Realms, SortKey}; @@ -67,6 +70,23 @@ const COL_TYPE: (f32, f32) = (235.0, 50.0); const COL_PLAYERS: (f32, f32) = (336.0, 32.0); const COL_LOAD: (f32, f32) = (418.0, 115.0); +/// `RealmListTab1`'s BOTTOMLEFT sits at the plate's BOTTOMLEFT (11, −15), so the 32-tall tabs hang +/// below the plate's lower edge. +const TAB_LEFT: f32 = 11.0; +const TAB_H: f32 = 32.0; +const TAB_TOP: f32 = BG_H + 15.0 - TAB_H; +/// Each next tab's LEFT at the one before's RIGHT −15. +const TAB_OVERLAP: f32 = 15.0; +/// `$parentLeft` and `$parentRight`, 20 wide; `GlueTemplates_TabResize(0)` makes the middle the +/// label's own width. +const TAB_END: f32 = 20.0; +/// `$parentLeftDisabled` at TOPLEFT (0, 3): the front tab's art stands 3 higher. +const TAB_FRONT_RISE: f32 = 3.0; +/// `$parentText` at CENTER (0, 2), and the highlight's two anchors at y 2. +const TAB_TEXT_RISE: f32 = 2.0; +/// `$parentHighlightTexture`, anchored 10 in from each side; its height is its art's, 32. +const TAB_HILIGHT_INSET: f32 = 10.0; + /// Root of the realm list; `with_art` and `s` trigger a rebuild when the art lands or the scale /// changes. #[derive(Component)] @@ -87,6 +107,8 @@ pub(super) enum RealmAction { /// declares no sound) where Cancel plays `gsLoginChangeRealmCancel`. Close, Sort(SortKey), + /// `RealmListTab`: the category tab with that 1-based ordinal. + Tab(usize), } /// The screen row of a row button and of each of its four texts. @@ -107,6 +129,14 @@ pub(super) struct RowHighlight; /// The Okay button, so the refresh can grey it out. #[derive(Component)] pub(super) struct OkButton; +/// A category tab's button, label and art pieces, by the tab's 1-based ordinal. +#[derive(Component, Clone, Copy)] +pub(super) struct TabOf(pub(super) usize); +/// One of a tab's six art pieces: the front tab's `ActiveTab` set, or the others' `InActiveTab`. +#[derive(Component, Clone, Copy)] +pub(super) struct TabArt { + front: bool, +} /// Spawn, rebuild and despawn the dialog as `shown` says, over whatever glue screen is current. pub(super) fn drive_screen( @@ -205,6 +235,7 @@ fn spawn_screen( spawn_header(b, art, &font, &text("SERVER_SELECTION"), s); spawn_close(b, art, &font, s); spawn_sort_headers(b, art, &font, strings, s); + spawn_tabs(b, art, &font, s); spawn_highlight(b, art, s); for row in 0..MAX_ROWS { spawn_row(b, &font, row, s); @@ -442,6 +473,108 @@ fn spawn_sort_headers( } } +/// `RealmListTab1`..`8` (`RealmListTabButtonTemplate`), spawned hidden in a row that chains each +/// tab 15 into the one before. A tab is its two 20-wide ends round a middle as wide as the label, +/// in both art sets; [`paint_tabs`] labels them and picks the set. +fn spawn_tabs(b: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle, s: f32) { + let px = |v: f32| Val::Px(v * s); + let mut strip = b.spawn(Node { + position_type: PositionType::Absolute, + left: px(TAB_LEFT), + top: px(TAB_TOP), + height: px(TAB_H), + flex_direction: FlexDirection::Row, + ..default() + }); + strip.with_children(|strip| { + for ordinal in 1..=MAX_TABS { + let overlap = if ordinal == 1 { 0.0 } else { -TAB_OVERLAP }; + let mut tab = strip.spawn(( + RealmAction::Tab(ordinal), + TabOf(ordinal), + Button, + GlueDisabled(false), + Visibility::Hidden, + Node { + height: px(TAB_H), + flex_shrink: 0.0, + margin: UiRect::left(px(overlap)), + padding: UiRect::horizontal(px(TAB_END)), + align_items: AlignItems::Center, + ..default() + }, + )); + tab.with_children(|t| { + for (front, sheet) in [(false, &art.tab_inactive), (true, &art.tab_active)] { + let Some((tex, size)) = sheet else { + continue; + }; + let top = if front { -TAB_FRONT_RISE } else { 0.0 }; + for (piece, tc) in REALM_TAB_TC.iter().enumerate() { + let (left, right, width) = match piece { + 0 => (px(0.0), Val::Auto, px(TAB_END)), + 1 => (px(TAB_END), px(TAB_END), Val::Auto), + _ => (Val::Auto, px(0.0), px(TAB_END)), + }; + t.spawn(( + TabOf(ordinal), + TabArt { front }, + Visibility::Hidden, + ImageNode { + image: tex.clone(), + rect: Some(tc_rect(*size, *tc)), + ..default() + }, + Node { + position_type: PositionType::Absolute, + left, + right, + width, + top: px(top), + height: px(TAB_H), + ..default() + }, + )); + } + } + if let Some(hi) = &art.tab_highlight { + t.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(hi.clone()), + Node { + position_type: PositionType::Absolute, + left: px(TAB_HILIGHT_INSET), + right: px(TAB_HILIGHT_INSET), + top: px(-TAB_TEXT_RISE), + height: px(TAB_H), + ..default() + }, + )); + } + let label = outlined_text( + t, + Node { + top: px(-TAB_TEXT_RISE), + ..default() + }, + (), + (), + GlueText { + text: "", + size: 12.0, // GlueFontNormalSmall + color: GOLD, + wrap: false, + }, + font, + s, + ); + t.commands().entity(label).insert(TabOf(ordinal)); + }); + } + }); +} + /// `RealmListHighlight`: one band, moved to the selected row and vertex-coloured to match it. fn spawn_highlight(b: &mut ChildSpawnerCommands, art: &GlueArt, s: f32) { let mut band = b.spawn(( @@ -503,10 +636,40 @@ fn spawn_row(b: &mut ChildSpawnerCommands, font: &Handle, row: usize, s: f }); } -/// `RealmListUpdate`: write every visible row, each frame, since any realm's population moves -/// every band. +/// The tab strip's buttons, art pieces and labels, kept apart from the rows' by their markers. +#[derive(SystemParam)] #[allow(clippy::type_complexity)] -pub(super) fn refresh_rows( +pub(super) struct TabNodes<'w, 's> { + tabs: Query< + 'w, + 's, + ( + &'static TabOf, + &'static Interaction, + &'static mut Visibility, + &'static mut GlueDisabled, + ), + ( + With, + Without, + Without, + Without, + ), + >, + arts: Query< + 'w, + 's, + (&'static TabOf, &'static TabArt, &'static mut Visibility), + (Without, Without), + >, + labels: + Query<'w, 's, (&'static TabOf, &'static mut Text, &'static mut TextColor), Without>, +} + +/// `RealmListUpdate`: the tab strip (`RealmList_UpdateTabs`), then every visible row, each frame, +/// since any realm's population moves every band. +#[allow(clippy::type_complexity)] +pub(super) fn refresh_list( realms: Res, strings: Option>, mut rows: Query< @@ -518,9 +681,11 @@ pub(super) fn refresh_rows( (&mut Node, &mut Visibility, Option<&mut ImageNode>), (With, Without), >, - mut ok: Query<&mut crate::glue::widgets::GlueDisabled, With>, + mut ok: Query<&mut GlueDisabled, With>, + mut tab_nodes: TabNodes, window: Query<&Window, With>, ) { + paint_tabs(&realms, &mut tab_nodes); let s = crate::glue::screen_scale(window.single().ok()); let visible = realms.rows(); let (mean, stddev) = realms.stats(); @@ -602,3 +767,202 @@ pub(super) fn refresh_rows( } } } + +/// `RealmList_UpdateTabs` and `GlueTemplates_UpdateTabs`: one category hides the strip; two to +/// eight show that many tabs, each labelled with its category's name. The front tab is +/// `Disable()`d on the `ActiveTab` art in its `DisabledFont` (`GlueFontHighlightSmall`, white); +/// the others stand on `InActiveTab` in gold, white with the highlight under the cursor. +fn paint_tabs(realms: &Realms, nodes: &mut TabNodes) { + let TabNodes { tabs, arts, labels } = nodes; + let names = realms.tabs(); + let shown = if names.len() >= 2 { names.len() } else { 0 }; + let front = realms.category(); + let mut hovered = None; + for (TabOf(ordinal), interaction, mut vis, mut disabled) in tabs.iter_mut() { + let want = if *ordinal <= shown { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *vis != want { + *vis = want; + } + let is_front = *ordinal == front; + if disabled.0 != is_front { + disabled.0 = is_front; + } + if !is_front && *interaction != Interaction::None { + hovered = Some(*ordinal); + } + } + for (TabOf(ordinal), art, mut vis) in arts.iter_mut() { + let want = if art.front == (*ordinal == front) { + Visibility::Inherited + } else { + Visibility::Hidden + }; + if *vis != want { + *vis = want; + } + } + for (TabOf(ordinal), mut text, mut color) in labels.iter_mut() { + let Some(name) = names.get(*ordinal - 1) else { + continue; + }; + if text.0 != *name { + text.0 = name.to_string(); + } + let c = if *ordinal == front || hovered == Some(*ordinal) { + load::HIGHLIGHT + } else { + GOLD + }; + if color.0 != c { + color.0 = c; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glue::{glue_clicks, GlueClicks}; + use benilla_protocol::RealmInfo; + + fn realm(name: &str, category: u8) -> RealmInfo { + RealmInfo { + name: name.into(), + address: "127.0.0.1:8085".into(), + population: 1.0, + characters: 0, + realm_type: 0, + flags: 0, + category, + id: 0, + } + } + + /// The tab strip as [`spawn_tabs`] builds it, both art sets stood in, under the US client's + /// categories, with the click and refresh passes the plugin runs. + fn tab_app(list: &[(&str, u8)]) -> App { + let mut app = App::new(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.init_resource::