diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 46ab7759..05b60ccd 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -114,7 +114,7 @@ pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog}; mod spells; pub use spells::{ load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types, - load_spell_durations, load_spell_radii, load_spell_ranges, substitute, OpenLock, + load_spell_durations, load_spell_radii, load_spell_ranges, substitute, FormRefusal, OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, @@ -196,6 +196,8 @@ mod area_poi; pub use area_poi::{load_area_poi_catalog, AreaPoi, AreaPoiCatalog}; mod area_table; pub use area_table::{load_area_table_catalog, AreaTableCatalog, AreaTableRow}; +mod race_sound; +pub use race_sound::{load_exploration_sound_catalog, ExplorationSoundCatalog}; mod zone_map; pub use zone_map::{load_zone_map, ZONE_MAP_EDGE}; diff --git a/crates/benilla-formats/src/race_sound.rs b/crates/benilla-formats/src/race_sound.rs new file mode 100644 index 00000000..4eed8b5d --- /dev/null +++ b/crates/benilla-formats/src/race_sound.rs @@ -0,0 +1,93 @@ +//! `ChrRaces.dbc` — the race-keyed **exploration sound** (decision 0829): the jingle the real +//! client plays on every `SMSG_EXPLORATION_EXPERIENCE`, resolved race → `ChrRaces` row → +//! column 3 (`+0xc`) → SoundEntries kit. Byte-verified in wow-re +//! (`system/net/net.md` § "SMSG_EXPLORATION_EXPERIENCE": handler case tail `0x5e41d2`, race byte +//! → row `[0xc0dee0][race]` → `+0xc` → play tail `0x458850`); the column's canonical *name* +//! ("ExplorationSoundID") is community lore, the offset and mechanism are the verified part. +//! +//! Layout — 29 columns (strings at 15, 26–28; the same schema +//! [`crate::characters`]'s creation catalog parses): only `ID` (0) and column 3 are read here. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; + +/// Race id (`ChrRaces` row id — the descriptor's `UNIT_FIELD_BYTES_0` race byte) → the +/// exploration-sound SoundEntries kit (rows with 0 are absent). +pub struct ExplorationSoundCatalog { + by_race: HashMap, +} + +impl ExplorationSoundCatalog { + /// The discovery jingle's kit for `race`, or `None` (unknown race, or a 0 field). + pub fn kit(&self, race: u32) -> Option { + self.by_race.get(&race).copied() + } + + pub fn len(&self) -> usize { + self.by_race.len() + } + + pub fn is_empty(&self) -> bool { + self.by_race.is_empty() + } +} + +/// Load the race → exploration-sound map off the patch chain. +pub fn load_exploration_sound_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(CHR_RACES) + .with_context(|| format!("reading {CHR_RACES}"))?; + 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 mut by_race = HashMap::new(); + for r in rs.records() { + let (Some(id), Some(kit)) = (u32_at(r, 0), u32_at(r, 3)) else { + continue; + }; + if kit != 0 { + by_race.insert(id, kit); + } + } + Ok(ExplorationSoundCatalog { by_race }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: every playable race (1–8) carries a nonzero exploration kit. + /// Skips without client data. + #[test] + fn real_chr_races_exploration_kits() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_exploration_sound_catalog(&mut chain).expect("load ChrRaces"); + for race in 1..=8u32 { + let kit = cat.kit(race); + eprintln!("race {race}: exploration kit {kit:?}"); + assert!(kit.is_some(), "race {race} has no exploration sound"); + } + // Frozen facts of the file: the playable races' kits are the contiguous SoundEntries + // block 4140–4147 (Human 4140, Orc 4141 — the misparse guard for the column position). + assert_eq!(cat.kit(1), Some(4140)); + assert_eq!(cat.kit(2), Some(4141)); + } +} diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 476a3136..8d7f4e38 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -533,6 +533,42 @@ fn real_spell_catalog_reads_usable_walk_columns() { assert!(!fireball.usable_in_form(1, false)); assert!(execute.usable_in_form(17, true)); assert!(!execute.usable_in_form(18, true)); + + // Ghost Wolf on the real rows (the shaman lockout, verified 2026-07-31): form 16 is a true + // shapeshift and cancelable; the spell carries NOT_SHAPESHIFT (bit 16) AND the stance-bar + // exclusion (ex2 0x2 — the shipped carrier: a shaman gets no stance bar). In the form, + // an ordinary spell refuses 0x3d; a form-requiring spell out of its form refuses 0x56. + let ghost_wolf = cat.get(2645).unwrap(); + assert_eq!(ghost_wolf.shapeshift_form, Some(16)); + assert_ne!(ghost_wolf.attributes & 0x1_0000, 0); + assert_ne!(ghost_wolf.attributes_ex2 & 0x2, 0); + let wolf_row = forms.get(&16).unwrap(); + assert!(!wolf_row.is_stance()); + assert!(wolf_row.cancelable()); + use crate::FormRefusal; + let bolt = cat.get(403).unwrap(); + assert_eq!( + bolt.form_refusal(16, false), + Some(FormRefusal::NotShapeshift) + ); + assert_eq!(bolt.form_refusal(0, false), None); + assert_eq!( + claw.form_refusal(0, false), + Some(FormRefusal::OnlyShapeshift) + ); + assert_eq!( + ghost_wolf.form_refusal(16, false), + Some(FormRefusal::NotShapeshift), + "re-pressing Ghost Wolf in the form draws the gate too (the cancel is a separate branch)" + ); + assert_eq!(FormRefusal::NotShapeshift.reason(), 0x3d); + assert_eq!(FormRefusal::OnlyShapeshift.reason(), 0x56); + + // The active-action toggle's raw-column gate (wow-re shapeshift-plaincast-toggle.md): + // Ghost Wolf carries a nonzero ActiveIconID, so its button press-again cancels; Battle + // Stance carries 0, which is what keeps a stance un-cancelable on the plain paths. + assert_ne!(ghost_wolf.active_icon_id, 0); + assert_eq!(cat.get(2457).unwrap().active_icon_id, 0, "Battle Stance"); } /// The tooltip-arc columns (decision 0274 P2) on the real build-5875 `Spell.dbc`, pinned diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index f8617230..c1d57f14 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -41,8 +41,10 @@ pub struct SpellDisplay { /// `AttributesEx` (column 7, `SpellRec+0x1c`) — only bit `0x10000000` is consumed /// ([`Self::hidden_from_aura_bar`]'s `SPELL_ATTR_EX_NO_AURA_ICON` half). pub attributes_ex: u32, - /// `AttributesEx2` (column 8, module docs) — only bit `0x20` is consumed - /// ([`Self::ranged_attack`]). + /// `AttributesEx2` (column 8, module docs) — bit `0x20` is consumed by + /// [`Self::ranged_attack`], bit 19 (`0x0008_0000`, allow-while-not-shapeshifted) by the form + /// gate [`Self::form_refusal`]; the stance-bar admission bits (`0x2`/`0x10`) are read raw by + /// `benilla::ui_shapeshift`. pub attributes_ex2: u32, /// `AttributesEx3` (column 9, `SpellRec+0x24`) — only bit `0x8000` is consumed /// ([`Self::melee_white_damage`]). @@ -142,6 +144,11 @@ pub struct SpellDisplay { /// `ActiveIconID`'s resolved texture path ([`COL_ACTIVE_ICON_ID`]) — shown on the stance /// button while this form is active, when present (druid forms); `None` falls back to `icon`. pub active_icon: Option, + /// `ActiveIconID` raw ([`COL_ACTIVE_ICON_ID`]) — the plain-path active-action toggle's gate + /// (`0x4e55f0`/`0x4b36f0` test the COLUMN, not the resolved texture; wow-re + /// `shapeshift-plaincast-toggle.md`): a nonzero id marks the spell press-again-to-cancel + /// while its own aura is live (`benilla::ui_action::toggle`). + pub active_icon_id: u32, /// `Description` enUS (column 138, module docs) — the tooltip body, raw `$`-token text /// un-substituted (decision 0274 P2's engine resolves the tokens; this is the source text). /// `None` when the column is empty. @@ -252,6 +259,7 @@ impl Default for SpellDisplay { shapeshift_form: None, stance_bar_order: 0, active_icon: None, + active_icon_id: 0, description: None, aura_description: None, duration_index: 0, @@ -491,30 +499,69 @@ impl SpellDisplay { .any(|a| TRACKING_AURA_TYPES.contains(a)) } - /// The shapeshift-form gate — the usable walk's leg 6 (`0x612480`, wow-re §2a: reads the - /// caster form, builds `1 << (form-1)`, tests **StancesNot** then **Stances**, then the - /// Attributes-bit-16 / AttributesEx2-bit-19 composition). The composition over the form's - /// stance flag is the vmangos corroboration (`SpellEntry::GetErrorAtShapeshiftedCast`, - /// `SpellEntry.cpp:1030` — anchored to exactly the byte-named fields): a *stance*-flagged - /// form (warrior stances, stealth — `SpellShapeshiftForm.flags1 & 1`) does not count as - /// "shapeshifted", so ordinary spells stay usable in it; a true shapeshift (cat, bear) - /// blocks NOT_SHAPESHIFT spells; and a form-requiring spell out of its form is unusable - /// unless AttributesEx2 bit 19 waives the requirement. + /// The shapeshift-form gate as a boolean — the usable walk's leg 6. See + /// [`Self::form_refusal`], the reason-carrying transcription this wraps. pub fn usable_in_form(&self, form: u8, form_is_stance: bool) -> bool { + self.form_refusal(form, form_is_stance).is_none() + } + + /// The shapeshift-form gate — `0x612480` (wow-re §2a: reads the caster form, builds + /// `1 << (form-1)`, tests **StancesNot** then **Stances**, then the Attributes-bit-16 / + /// AttributesEx2-bit-19 composition), which both the usable walk's leg 6 and the TryCast + /// requirement validator (`0x6094f0`, the leg right after the mounted block) run. The + /// composition over the form's stance flag — and the reason split — is the vmangos + /// corroboration (`SpellEntry::GetErrorAtShapeshiftedCast`, `SpellEntry.cpp` — anchored to + /// exactly the byte-named fields): a *stance*-flagged form (warrior stances, stealth — + /// `SpellShapeshiftForm.flags1 & 1`) does not count as "shapeshifted", so ordinary spells + /// stay usable in it; a true shapeshift (cat, bear, Ghost Wolf) blocks NOT_SHAPESHIFT + /// spells; and a form-requiring spell out of its form is refused unless AttributesEx2 + /// bit 19 waives the requirement. `None` = castable in this form. + pub fn form_refusal(&self, form: u8, form_is_stance: bool) -> Option { let stance_bit = if form == 0 { 0 } else { 1u32 << (form - 1) }; if self.stances_not & stance_bit != 0 { - return false; + return Some(FormRefusal::NotShapeshift); } if self.stances & stance_bit != 0 { - return true; + return None; } if form != 0 && !form_is_stance { - // A true shapeshift: NOT_SHAPESHIFT spells are blocked; a spell needing some - // other form is too. - self.attributes & ATTR_NOT_SHAPESHIFT == 0 && self.stances == 0 + // A true shapeshift: NOT_SHAPESHIFT spells are blocked (0x3d); a spell needing + // some other form is too (0x56). + if self.attributes & ATTR_NOT_SHAPESHIFT != 0 { + Some(FormRefusal::NotShapeshift) + } else if self.stances != 0 { + Some(FormRefusal::OnlyShapeshift) + } else { + None + } + } else if self.stances != 0 + && self.attributes_ex2 & ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED == 0 + { + // Unshifted (or a stance): a form-requiring spell is refused here unless waived. + Some(FormRefusal::OnlyShapeshift) } else { - // Unshifted (or a stance): a form-requiring spell is unusable here unless waived. - self.stances == 0 || self.attributes_ex2 & ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED != 0 + None + } + } +} + +/// A [`SpellDisplay::form_refusal`] verdict — which cast-fail reason `0x612480` writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FormRefusal { + /// `SPELL_FAILED_NOT_SHAPESHIFT` (0x3d, "Can't do that while shapeshifted"): blocked by the + /// current form — a StancesNot hit, or a NOT_SHAPESHIFT spell in a true shapeshift. + NotShapeshift, + /// `SPELL_FAILED_ONLY_SHAPESHIFT` (0x56, "Must be in %s"): the spell requires a form the + /// caster is not in. + OnlyShapeshift, +} + +impl FormRefusal { + /// The wire/cast-fail reason byte (the `SPELL_FAILED_*` table index). + pub fn reason(self) -> u8 { + match self { + FormRefusal::NotShapeshift => 0x3d, + FormRefusal::OnlyShapeshift => 0x56, } } } diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 2bc647d4..d565e293 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -122,7 +122,7 @@ mod tokens; pub use cast_times::{load_spell_cast_times, SpellCastTime, SpellCastTimeCatalog}; pub use dispel_types::{load_spell_dispel_types, SpellDispelTypes}; -pub use display::{OpenLock, SpellDisplay}; +pub use display::{FormRefusal, OpenLock, SpellDisplay}; pub use duration::{load_spell_durations, SpellDuration, SpellDurationCatalog}; pub use forms::{load_shapeshift_forms, ShapeshiftForm}; pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog}; @@ -632,6 +632,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { .then(|| u32_at(r, COL_EFFECT_MISC_1 + i).unwrap_or(0)) }), stance_bar_order: u32_at(r, COL_STANCE_BAR_ORDER).unwrap_or(0) as i32, + active_icon_id: u32_at(r, COL_ACTIVE_ICON_ID).unwrap_or(0), active_icon: u32_at(r, COL_ACTIVE_ICON_ID) .filter(|&i| i != 0) .and_then(|i| icons.get(&i).cloned()), diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index aca846ac..62f4fd15 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -11,13 +11,13 @@ use crate::messages::{ ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield, - EnvironmentalDamageLog, FriendEntry, FriendStatusUpdate, GossipOption, GroupLootInfo, - GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, - LootRoll, LootRollWon, LootStartRoll, MailListEntry, MonsterMoveFacing, ObjectFields, - PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, - QuestOfferReward, QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, - SpellHealLog, SpellLogMiss, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, - TransportPose, VendorItem, WhoResults, XpGain, + EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption, + GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, + LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, + MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, + QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate, + SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask, TradeStatus, + TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, }; /// Coarse entity classification, free of wire types so the app can branch on it without depending on @@ -587,6 +587,9 @@ pub enum SessionEvent { SpellLogMiss(SpellLogMiss), /// An XP award, kill or non-kill (`SMSG_LOG_XPGAIN`) — decision 0137 phase 2. XpGain(XpGain), + /// A first visit to an area (`SMSG_EXPLORATION_EXPERIENCE`) — the discovered area id + its + /// XP award; the "Discovered …" chat line's data (decision 0828). + ExplorationXp(ExplorationXp), /// Our own ding (`SMSG_LEVELUP_INFO`, self-addressed only) — decision 0304. LevelUp(LevelUpInfo), /// A gossip menu opened on an NPC (`SMSG_GOSSIP_MESSAGE`, answering our `CMSG_GOSSIP_HELLO`): diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index b51268e8..1dfb627d 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -204,6 +204,7 @@ pub fn decode(packet: ServerPacket) -> Vec { ServerPacket::EnvironmentalDamageLog(s) => vec![SessionEvent::EnvironmentalDamageLog(s)], ServerPacket::SpellLogMiss(s) => vec![SessionEvent::SpellLogMiss(s)], ServerPacket::XpGain(s) => vec![SessionEvent::XpGain(s)], + ServerPacket::ExplorationXp(s) => vec![SessionEvent::ExplorationXp(s)], ServerPacket::LevelUp(l) => vec![SessionEvent::LevelUp(l)], ServerPacket::GossipMessage { npc, diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 7db82d02..4052bc1b 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -123,7 +123,7 @@ pub use opcode_names::opcode_name; pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket}; pub use parse::parse_server; pub use pose::{set_sheathed, stand_state_change}; -pub use progression::{learn_talent, LevelUpInfo, XpGain}; +pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain}; pub use quest::{ dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 56aed377..a9e54ed5 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -179,6 +179,9 @@ pub const SMSG_UPDATE_AURA_DURATION: u16 = 0x0137; // 311 /// (`Player::GiveLevel` builds it with no guid and sends straight to the leveling session). Body /// in [`super::progression::read_level_up_info`] — decision 0304. pub const SMSG_LEVELUP_INFO: u16 = 0x01D4; // 468 +/// A newly explored area — area id + the XP it granted (0 at max level). Body +/// in [`super::progression::read_exploration_xp`] — decision 0828. +pub const SMSG_EXPLORATION_EXPERIENCE: u16 = 0x01F8; // 504 (VERIFIED vmangos `Opcodes_1_12_1.h:505`) // The combat-log wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 464/587/590/591/592) — decision 0137 // phase 2's floating-combat-text data feed. Bodies in [`super::spells`]. diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 259f14f6..1a184464 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -6,14 +6,14 @@ use crate::wire::Vector3d; use super::{ ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage, - CorpseLocation, DamageShield, EnvironmentalDamageLog, FriendEntry, FriendStatusUpdate, - GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry, InitWorldStates, ItemInfo, - ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, - LootStartRoll, MailListEntry, Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, - QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, - ResurrectRequestBody, SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, - SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, - TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, + CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, + FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry, + InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, + LootRoll, LootRollWon, LootStartRoll, MailListEntry, Object, PartyMemberStatsInfo, + PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, + QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellCooldown, + SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, + TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, }; /// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this @@ -508,6 +508,9 @@ pub enum ServerPacket { /// `SMSG_LOG_XPGAIN` — an XP award, kill or non-kill (decision 0137 phase 2; layout in /// [`super::progression::read_xp_gain`]). XpGain(XpGain), + /// `SMSG_EXPLORATION_EXPERIENCE` — a first visit to an area: the discovered area id + its XP + /// award (decision 0828; layout in [`super::progression::read_exploration_xp`]). + ExplorationXp(ExplorationXp), /// `SMSG_LEVELUP_INFO` — our own ding, self-addressed only (decision 0304; layout in /// [`super::progression::read_level_up_info`]). LevelUp(LevelUpInfo), @@ -1093,6 +1096,7 @@ impl ServerPacket { ServerPacket::EnvironmentalDamageLog(_) => "SMSG_ENVIRONMENTALDAMAGELOG".into(), ServerPacket::SpellLogMiss(_) => "SMSG_SPELLLOGMISS".into(), ServerPacket::XpGain(_) => "SMSG_LOG_XPGAIN".into(), + ServerPacket::ExplorationXp(_) => "SMSG_EXPLORATION_EXPERIENCE".into(), ServerPacket::LevelUp(_) => "SMSG_LEVELUP_INFO".into(), ServerPacket::QuestGiverStatus { .. } => "SMSG_QUESTGIVER_STATUS".into(), ServerPacket::QuestGiverQuestList(_) => "SMSG_QUESTGIVER_QUEST_LIST".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index d075aea8..218d433c 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -479,6 +479,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { ServerPacket::SpellLogMiss(combat_log::read_spell_log_miss(&mut r)?) } opcode::SMSG_LOG_XPGAIN => ServerPacket::XpGain(progression::read_xp_gain(&mut r)?), + opcode::SMSG_EXPLORATION_EXPERIENCE => { + ServerPacket::ExplorationXp(progression::read_exploration_xp(&mut r)?) + } opcode::SMSG_LEVELUP_INFO => { ServerPacket::LevelUp(progression::read_level_up_info(&mut r)?) } diff --git a/crates/benilla-protocol/src/messages/progression.rs b/crates/benilla-protocol/src/messages/progression.rs index 38babc80..11d88a68 100644 --- a/crates/benilla-protocol/src/messages/progression.rs +++ b/crates/benilla-protocol/src/messages/progression.rs @@ -44,6 +44,27 @@ pub(super) fn read_xp_gain(r: &mut impl Read) -> io::Result { }) } +/// One decoded `SMSG_EXPLORATION_EXPERIENCE` — a first visit to an area: the newly explored +/// `AreaTable.dbc` row **id** (not the explore-flag bit) and the XP the discovery granted +/// (vmangos `WorldPackets::Misc::ExplorationExperience`, `Server/Packets/Misc.h:838-846` + +/// `Misc.cpp:552-556`; filled by `Player::CheckAreaExploreAndOutdoor`, Player.cpp:6228-6341, +/// which sends it **even when xp is 0** — max level, or an area with no level). The XP itself +/// also rides a separate non-kill [`XpGain`]; this packet is what names the area. Decision 0828. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExplorationXp { + /// The `AreaTable.dbc` row id of the area just discovered. + pub area_id: u32, + /// The exploration XP awarded (0 at max level / unleveled areas). + pub xp: u32, +} + +/// Read `SMSG_EXPLORATION_EXPERIENCE`: areaId u32 · xp u32 (8 bytes, `Misc.cpp:552-556`). +pub(super) fn read_exploration_xp(r: &mut impl Read) -> io::Result { + let area_id = read_u32_le(r)?; + let xp = read_u32_le(r)?; + Ok(ExplorationXp { area_id, xp }) +} + /// Body of `CMSG_LEARN_TALENT` (vmangos `Server/Packets/Skill.h:10-19` + /// `Player::LearnTalent`, Player.cpp:20807): `u32 talentId` (a `Talent.dbc` row id) + /// `u32 requestedRank`, **0-based** — requesting rank k learns *up to* k (the server spends diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index 4f0f5418..e277fd10 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -590,8 +590,8 @@ fn spell_go_surfaces_the_gameobject_target() { #[test] fn combat_log_wire_golden() { use benilla_protocol::messages::{ - DamageShield, EnvironmentalDamageLog, LevelUpInfo, PeriodicAuraLog, PeriodicTick, - SpellDamageLog, SpellLogMiss, XpGain, + DamageShield, EnvironmentalDamageLog, ExplorationXp, LevelUpInfo, PeriodicAuraLog, + PeriodicTick, SpellDamageLog, SpellLogMiss, XpGain, }; let creature = 0xF130_0000_4500_002Au64; // packs mask 0xC9 (same fixture guid as elsewhere) @@ -859,6 +859,31 @@ fn combat_log_wire_golden() { other => panic!("xp gain event, got {other:?}"), } + // SMSG_EXPLORATION_EXPERIENCE: areaId u32 (an AreaTable.dbc row id) + xp u32 — exactly + // 8 bytes (VERIFIED vmangos Misc.cpp:552-556; sent even when xp is 0). The vector is + // Westfall (area 40) worth 85 xp. + let mut body = 40u32.to_le_bytes().to_vec(); + body.extend_from_slice(&85u32.to_le_bytes()); + assert_eq!(body.len(), 8); + let packet = + messages::parse_server(messages::opcode::SMSG_EXPLORATION_EXPERIENCE, &body).unwrap(); + match &packet { + ServerPacket::ExplorationXp(x) => { + assert_eq!( + x, + &ExplorationXp { + area_id: 40, + xp: 85, + } + ); + } + other => panic!("exploration xp, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::ExplorationXp(x) => assert_eq!((x.area_id, x.xp), (40, 85)), + other => panic!("exploration xp event, got {other:?}"), + } + // SMSG_LEVELUP_INFO: twelve u32 — level, healthGain, powerGains[5] (mana..happiness), // statGains[5] (str..spirit). VERIFIED vmangos Misc.cpp:524-532; exactly 48 bytes, no guid // (self-addressed). The vector is a caster-flavored ding: mana + int/spirit heavy. diff --git a/crates/benilla/src/assets/mod.rs b/crates/benilla/src/assets/mod.rs index 3a73ff4f..e422151f 100644 --- a/crates/benilla/src/assets/mod.rs +++ b/crates/benilla/src/assets/mod.rs @@ -414,6 +414,10 @@ pub(crate) struct RenderConfig { pub(crate) tile_radius: u32, /// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8). pub(crate) tex_tiles: f32, + /// Stale tiles released per frame on a within-map window shift (`$WOW_TILE_UNLOAD`, + /// default 1; `0` = unbudgeted — the whole trailing row in one frame, the pre-B181 + /// behaviour, kept as the controlled A/B leg on one build). + pub(crate) unload_budget: usize, } /// Startup ordering seam: [`AssetPlugin`] opens the patch chain in this set so every other @@ -484,9 +488,16 @@ fn open_world_assets(mut commands: Commands, device: Res) { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(8.0); + // See the field doc: the tile-unload budget (B181). Default 1 — even the fastest focus + // (boosted free-fly, ~1 stale row/s) produces stale tiles far slower than 60/s drains them. + let unload_budget = std::env::var("WOW_TILE_UNLOAD") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1); commands.insert_resource(RenderConfig { tile_radius, tex_tiles, + unload_budget, }); // The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index a309c06c..afc29409 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -1001,7 +1001,6 @@ fn fire_particle_census( emitters: Query<( &crate::particles::ParticleEmitter, Option<&crate::particles::EmitterFade>, - &Visibility, Option<&bevy::camera::visibility::RenderLayers>, )>, ) { @@ -1037,7 +1036,7 @@ fn fire_particle_census( let mut drawn_beyond_wall_live = 0usize; let mut booth = 0usize; let mut max_drawn_depth = f32::NEG_INFINITY; - for (e, fade, vis, layers) in &emitters { + for (e, fade, layers) in &emitters { let world_layer = layers.is_none_or(|l| l.intersects(&bevy::camera::visibility::RenderLayers::default())); // Depth to the OWNER's fade sphere where there is one (the gate's own subject), else the @@ -1047,7 +1046,7 @@ fn fire_particle_census( let radius = fade.map_or(0.0, |f| f.radius); (center - t.translation()).dot(Vec3::from(t.forward())) - radius }); - let drawn = *vis != Visibility::Hidden; + let drawn = e.drawn(); if !world_layer { booth += 1; } diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index da983105..9838f052 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -533,6 +533,15 @@ impl Plugin for EntitiesPlugin { retire_unit_appear_fade, ) .chain(), + ) + // …and publish those same ramps as ONE number per model instance, for the consumers + // that are not meshes and so cannot read a `MeshTag`: an emitter's particles and a + // ribbon's strip (decision 0827 — the reference's `CM2Model+0x19c`). PostUpdate, so + // every Update-side fade writer and the camera controller's self feather have already + // run this frame, and before the effect sims that read it. + .add_systems( + PostUpdate, + crate::model_fade::publish_model_alpha.before(crate::billboard::BillboardPlace), ); } } diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index eecd9d27..10834ae2 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -1026,9 +1026,20 @@ pub(super) fn attach_entity_visuals( &mut commands, em, placement, - Some(owner), - None, // a unit's OWN model is not an attached model (`[model+0x17c]` = 0) - Some(entity), // the cloud anchors at the unit; bones compose births only + particles::EmitterFrames { + owner: Some(owner), + // A unit's OWN model is not an attached model (`[model+0x17c]` = 0). + attach: None, + // The cloud anchors at the unit; bones compose births only. + anchor: Some(entity), + // The unit's model going away IS this emitter's model going away + // (stream-out, a visual rebuild) — free the pool with it (0826). + on_owner_loss: particles::OwnerLoss::Free, + // The unit IS the model instance here, so its own render alpha + // (appear ramp, stream-out ramp, the self-avatar feather) multiplies + // its clouds — decision 0827. + alpha: Some(entity), + }, // The emitters' rate/enabled read this instance's PLAYING sequence — a // unit's or GameObject's `AnimationPlayer` on the root. A quest object's // explosion is authored inside its one-shot clips with an OFF window at @@ -1066,6 +1077,8 @@ pub(super) fn attach_entity_visuals( use_pivot, placement.scale.max_element(), None, + // The unit's own render alpha gates its trail (0827). + Some(entity), ); } } diff --git a/crates/benilla/src/entities/equipment/mod.rs b/crates/benilla/src/entities/equipment/mod.rs index 953de762..9c24e044 100644 --- a/crates/benilla/src/entities/equipment/mod.rs +++ b/crates/benilla/src/entities/equipment/mod.rs @@ -206,7 +206,7 @@ pub(super) struct HeldItems { /// Total attach sub-model slots: the 3 held + helm + shoulder L/R + the nocked ammo + the /// worn quiver (self-only, ranged-drawn — wow-re `nocked-ammo-cancel.md` §H). -const ATTACH_SLOTS: usize = 8; +pub(super) const ATTACH_SLOTS: usize = 8; #[derive(Clone, Copy, PartialEq, Eq)] struct HeldSlot { @@ -215,11 +215,22 @@ struct HeldSlot { attach: u16, /// The `ItemVisuals.dbc` id this item glows with — its display's intrinsic visual, else its /// first enchant's (decision 0805, [`super::item_glow`]). `0` on the overwhelming majority. - /// Part of the diff key on purpose: applying or losing an enchant re-spawns the item so the - /// glow follows, exactly like a sheath change does. + /// Part of the diff key on purpose: applying or losing an enchant is a different MODEL set, so + /// the item is rebuilt and the glow follows. (A sheath change is not — same item, new attach + /// point: that one MOVES the spawned root instead, decision 0826.) visual: i32, } +impl HeldSlot { + /// The same item model, wherever it hangs — the test that separates a **move** (the sheath + /// swap: one item, a new attach point, so the spawned root is re-parented and everything + /// riding it comes along — decision 0826) from a **rebuild** (a different display, model + /// variant, or glow, which is a different model and must be built from scratch). + fn same_item(&self, other: &Self) -> bool { + self.display == other.display && self.kind == other.kind && self.visual == other.visual + } +} + /// Which of an item display's models a slot shows, and where its file lives (decision 0074 slice 3c /// — all pinned empirically against the real MPQ listing): /// `Item\ObjectComponents\{Weapon,Shield}\model[0]` for held items; `Shoulder\model[0]`/`model[1]` @@ -270,11 +281,13 @@ impl BoneAttach { } /// The held-item children currently spawned for a unit: the [`HeldItems`] they were built from (the -/// diff key) + the spawned root entities (one per slot, despawned recursively on change). +/// diff key) + the spawned root entity **per slot** — indexed, not a flat list, because the diff is +/// per slot (decision 0826): a slot that didn't change keeps its root, and a slot that only changed +/// attach point has its root moved. Only a slot whose item really changed is despawned and rebuilt. #[derive(Component, Default)] pub(super) struct HeldAttached { applied: HeldItems, - spawned: Vec, + spawned: [Option; ATTACH_SLOTS], } /// The glow id of a slot the reference never glows: **helm (attach 11), shoulders (5/6) and the diff --git a/crates/benilla/src/entities/equipment/spawn.rs b/crates/benilla/src/entities/equipment/spawn.rs index d94aef63..564da051 100644 --- a/crates/benilla/src/entities/equipment/spawn.rs +++ b/crates/benilla/src/entities/equipment/spawn.rs @@ -14,8 +14,10 @@ use crate::model_fade::{ UnitAppearFade, APPEAR_FADE_SECS, }; -use super::super::spawn_carried_lights; -use super::{attach_id, BoneAttach, HeldAttached, HeldItems, ItemDisplays, NO_GLOW}; +use super::super::{item_glow::ItemGlow, spawn_carried_lights}; +use super::{ + attach_id, BoneAttach, HeldAttached, HeldItems, HeldSlot, ItemDisplays, ATTACH_SLOTS, NO_GLOW, +}; /// A held-item part's resolved appear-fade join state for this spawn, folding [`JoinedFade`] with /// whether the part itself is fade-capable ([`super::EntityPart::fade_blend`]). @@ -26,12 +28,91 @@ enum PartFade { Live(f32), } +/// Everything one slot's spawn needs from its WEARER, read once per unit — the context +/// [`spawn_slot`] carries so the per-slot body isn't a fifteen-argument call. +struct WearerCtx<'a> { + /// The unit wearing the item — the parent whose tint, light collector and fade it inherits. + wearer: Entity, + bones: &'a BoneAttach, + /// The unit's appear-fade clock, for a part spawning mid-ramp ([`join_unit_appear_fade`]). + joined: JoinedFade, + now: f32, + /// The wearer's rig-palette slot, pre-shifted into `MeshTag` bits (decision 0812). + rig_tag: u32, + /// The wearer's body bake centre, when it has one — the interior classifier's fold reference. + body_center: Option, + /// The wire scale the unit renders at: the world yards its held effects' draw-order rung is + /// measured in ([`crate::particles::owner_last_bias`]). + scale: f32, +} + +/// The **re-seat writers** (decision 0826): everything under an item root that caches *where on the +/// body* the item sits. A pure attach-point change — the sheath swap — MOVES the root instead of +/// rebuilding it, and these move by the same delta, so the item's glow instances, its effect hosts +/// and its live particle clouds all ride along instead of being orphaned and respawned. +#[derive(bevy::ecs::system::SystemParam)] +pub(in crate::entities) struct SeatWriters<'w, 's> { + children: Query<'w, 's, &'static Children>, + riders: Query<'w, 's, &'static mut crate::portrait::PortraitRider>, + cards: Query<'w, 's, &'static mut crate::portrait::PortraitBillboard>, + effects: Query<'w, 's, &'static mut crate::portrait::PortraitEffects>, + glows: Query<'w, 's, &'static mut ItemGlow>, +} + +impl SeatWriters<'_, '_> { + /// Move every cached seat in `root`'s subtree to the item's new attach point: the body `bone` + /// it now hangs from, and `delta` = `new_offset − old_offset` applied to each cached offset. + /// + /// The delta (rather than an assignment) is what makes this total: a mirror's offset is the + /// attach point *plus* something model-local — a card's own pivot, a glow slot's point on the + /// item — and only the attach-point term moves. The walk is recursive because an item's glow + /// instances hang two levels down. + fn reseat(&mut self, root: Entity, bone: u16, delta: Vec3) { + let mut stack = vec![root]; + while let Some(e) = stack.pop() { + if let Ok(mut r) = self.riders.get_mut(e) { + r.bone = bone; + r.offset += delta; + } + if let Ok(mut c) = self.cards.get_mut(e) { + c.bone = bone; + c.seat = match c.seat { + crate::portrait::PortraitSeat::Body => crate::portrait::PortraitSeat::Body, + crate::portrait::PortraitSeat::Rider(at) => { + crate::portrait::PortraitSeat::Rider(at + delta) + } + }; + } + if let Ok(mut f) = self.effects.get_mut(e) { + f.bone = bone; + f.offset += delta; + } + if let Ok(mut g) = self.glows.get_mut(e) { + g.bone = bone; + g.offset += delta; + } + if let Ok(kids) = self.children.get(e) { + stack.extend(kids.iter()); + } + } + } +} + /// Spawn/refresh the held-item children for every unit whose [`HeldItems`] changed (or whose item /// model finished loading): each slot's model parts spawn under the attach point's joint entity at /// the attachment offset, so they ride the bone. Slots whose model is still loading are left pending /// (the `applied` diff key keeps them un-applied) and picked up on a later pass. A part spawning while /// the unit's own appear-fade is still in flight joins it ([`join_unit_appear_fade`]) instead of /// popping in opaque (decision 0032 read as a per-unit property). +/// +/// **The diff is per SLOT, and an attach-point change is a MOVE** (decision 0826). The reference's +/// sheath paths touch only the weapon/quiver attach ids (`0x611770`, wow-re `sheath-policy.md` / +/// `ranged-sheath-display.md`) and stow a melee weapon by detaching the sub-model and **re-parenting +/// it** at the sheath point (`0x60b590` → `0x712f70`) — the model instance, and everything riding +/// it, survives the swap. Rebuilding a unit's whole kit on any change did neither: drawing a sword +/// respawned the shoulders' and helm's emitters too, and every orphaned pool then lived out its +/// lifespan FROZEN in world space (`particles::sim`'s drain) — the sparkle cloud that hung behind +/// the character on every weapon draw and every step of the login gear cascade. #[allow(clippy::type_complexity)] pub(in crate::entities) fn attach_held_items( mut commands: Commands, @@ -55,19 +136,27 @@ pub(in crate::entities) fn attach_held_items( )>, held: Option>, time: Res