zone discovery says so, and tile crossings stop churning

- crossing into a new zone lands the whole discovery moment: the "Discovered" toast, the experience line in chat, and the race-keyed jingle
- crossing a tile boundary no longer recycles a whole row of terrain: a keep-band holds what you just left, unloading is budgeted, and fresh requests stagger instead of bursting
- a sheath swap moves the weapon (and its sparkles) to the new slot instead of rebuilding it, and a fading model now dims its own effects with it
- pressing the cast button again cancels the cast, and a form that can't cast a spell refuses it locally instead of round-tripping the server

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-31 17:00:40 +07:00
co-authored by Claude Fable 5
parent 9562380a64
commit 76cc038998
47 changed files with 2126 additions and 467 deletions
+3 -1
View File
@@ -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};
+93
View File
@@ -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<u32, u32>,
}
impl ExplorationSoundCatalog {
/// The discovery jingle's kit for `race`, or `None` (unknown race, or a 0 field).
pub fn kit(&self, race: u32) -> Option<u32> {
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<ExplorationSoundCatalog> {
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));
}
}
@@ -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
+65 -18
View File
@@ -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<String>,
/// `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<FormRefusal> {
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,
}
}
}
+2 -1
View File
@@ -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<SpellCatalog> {
.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()),
+10 -7
View File
@@ -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`):
@@ -204,6 +204,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
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,
+1 -1
View File
@@ -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,
@@ -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`].
+12 -8
View File
@@ -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(),
@@ -479,6 +479,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
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)?)
}
@@ -44,6 +44,27 @@ pub(super) fn read_xp_gain(r: &mut impl Read) -> io::Result<XpGain> {
})
}
/// 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<ExplorationXp> {
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
+27 -2
View File
@@ -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.
+11
View File
@@ -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<RenderDevice>) {
.ok()
.and_then(|s| s.parse::<f32>().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
+2 -3
View File
@@ -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;
}
+9
View File
@@ -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),
);
}
}
+16 -3
View File
@@ -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),
);
}
}
+18 -5
View File
@@ -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<Entity>,
spawned: [Option<Entity>; ATTACH_SLOTS],
}
/// The glow id of a slot the reference never glows: **helm (attach 11), shoulders (5/6) and the
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -155,7 +155,9 @@ pub(in crate::entities) struct ItemGlow {
/// Set once an item root's glow instances are spawned (or resolved to nothing) — the once-only
/// gate. The instances are children of the root, so they need no lifetime bookkeeping of their
/// own: a gear change, a sheath swap, or the unit despawning takes the root and them with it.
/// own: a gear change or the unit despawning takes the root and them with it. A **sheath swap**
/// keeps them: it moves the root to the new attach point (decision 0826), so a glowing weapon
/// keeps its glow — and its live particles — across the draw.
#[derive(Component)]
pub(super) struct ItemGlowAttached;
+12 -5
View File
@@ -503,11 +503,16 @@ pub(super) fn attach_effect_visuals(
commands,
em,
Transform::IDENTITY,
Some(owner),
attach,
// The cloud anchors at the instance root — the emitter's bone only composes births
// (the food sparkle's bone orbits; the risen stars must not swirl with it).
Some(root),
particles::EmitterFrames {
owner: Some(owner),
attach,
// The cloud anchors at the instance root — the emitter's bone only composes births
// (the food sparkle's bone orbits; the risen stars must not swirl with it).
anchor: Some(root),
// An effect instance that ends mid-flight leaves its live particles to finish
// (the impacting missile's trail) — the DRAIN default.
..default()
},
// The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot
// (a missile's InFlight is not file-order-first), on the instance's own spawn clock.
played_seq.map_or(
@@ -533,6 +538,8 @@ pub(super) fn attach_effect_visuals(
// families land on the same draw-order rung, which is the property that matters.
1.0,
played_anim,
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
);
}
true
+15 -6
View File
@@ -313,12 +313,19 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut commands,
em,
placement,
Some(owner),
None, // a placed prop is never an attached model
// The cloud anchors at the PROP: the boat carries the risen flame (the
// reference re-anchors every cloud to the emitter's live position), while
// an animated bone still never drags it.
Some(root),
particles::EmitterFrames {
owner: Some(owner),
attach: None, // a placed prop is never an attached model
// The cloud anchors at the PROP: the boat carries the risen flame (the
// reference re-anchors every cloud to the emitter's live position),
// while an animated bone still never drags it.
anchor: Some(root),
// A placed prop's model is destroyed when its placement unloads.
on_owner_loss: particles::OwnerLoss::Free,
// A placed prop carries no fade component of its own; its emitters
// take the doodad distance fade in the sim instead (0827).
alpha: None,
},
// A placed prop: the doodad law — which is NOT one arm for life. It re-rolls
// its variation every play-window (decision 0768), so the emitter resolves
// the slot + clip time off the host's live player each frame.
@@ -348,6 +355,8 @@ pub(super) fn spawn_wmo_gameobject_props(
use_pivot,
placement.scale.max_element(),
None,
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
)
.is_some()
{
+150
View File
@@ -143,6 +143,103 @@ pub fn fade_alpha(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * t * t * t
}
/// One model instance's live **render alpha** — the reference's `CM2Model+0x19c`, and the single
/// slot every fade in the client writes through (wow-re object-layer/`appear-fade` §"the alpha
/// slot"): `+0x19c = argAlpha · +0x180`, with `+0x180 = obj+0x100 (master) · obj+0xf4 (the appear
/// ramp)` for a CGObject and the distance fade for a map doodad.
///
/// benilla keeps that alpha on the MESH side in the per-part `MeshTag` (the appear/despawn ramp,
/// the self-avatar feather, the doodad fade — three writers, one channel). This component is the
/// same number published **per model instance**, for the consumers that are not meshes and cannot
/// read a `MeshTag`: an emitter's particles and a ribbon's strip, whose alpha is per-vertex colour
/// (decision 0827). Missing ⇒ `1.0`.
///
/// An **attached** model inherits its parent's, which is why an item's effects read their WEARER's
/// (the `0x714000` recursion: a child model with `[model+0x1cc] ≠ 0` composes onto the parent's
/// computed colours and alpha — wow-re `selection-circle.md` §scope, `0x714260`'s `[ebp+0x14]`).
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub struct ModelAlpha(pub f32);
/// The render alpha of one streamed model instance this frame — pure, so the composition is pinned
/// by tests without an ECS (the `model_fade` pattern):
///
/// - the **appear** ramp: none ⇒ opaque; pending ⇒ 0 (the entity is not being shown yet, so its
/// effects must not be either — this is the login symptom); live ⇒ the cubic ramp,
/// - × the **despawn** ramp (our stream-out look, 0032/0067) once armed,
/// - × the **self-avatar** zoom feather, which applies to the player's own body alone.
///
/// The product is the reference's own shape: it multiplies the transition alpha into the same
/// `+0x180` slot rather than keeping a second channel.
pub fn model_render_alpha(
now: f32,
appear: Option<UnitAppearFade>,
despawn_started: Option<f32>,
self_fade: f32,
) -> f32 {
let appear = match appear {
None => 1.0,
Some(UnitAppearFade::Pending { .. }) => 0.0,
Some(UnitAppearFade::Live { started }) => {
fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS)
}
};
let despawn = despawn_started.map_or(1.0, |started| {
fade_alpha(1.0, 0.0, (now - started) / APPEAR_FADE_SECS)
});
(appear * despawn * self_fade).clamp(0.0, 1.0)
}
/// Publish [`ModelAlpha`] on every streamed object each frame. Runs in `PostUpdate`, after every
/// Update-side fade writer and the camera controller that computes the self feather, and before the
/// effect sims that consume it ([`crate::particles`], [`crate::ribbons`]).
///
/// Opaque is the default and costs nothing: an entity that has never faded gets no component at all
/// (a missing one reads `1.0`), so the steady-state world carries none of these.
#[allow(clippy::type_complexity)] // one query, five optional facets of the same entity
pub(crate) fn publish_model_alpha(
time: Res<Time>,
// `Option`: the rig is the world app's (a booth-only or test app has none) — the same shape
// `blob_shadow` and `nameplates` read the self feather through.
rig: Option<Res<crate::player::CameraControl>>,
mut commands: Commands,
mut units: Query<
(
Entity,
Option<&UnitAppearFade>,
Option<&DespawnFade>,
Has<crate::net::SelfPlayer>,
Option<&mut ModelAlpha>,
),
With<crate::net::NetEntity>,
>,
) {
let now = time.elapsed_secs();
for (entity, appear, despawn, is_self, current) in &mut units {
let alpha = model_render_alpha(
now,
appear.copied(),
despawn.map(|d| d.started).filter(|s| *s >= 0.0),
if is_self {
rig.as_deref()
.map_or(1.0, crate::player::CameraControl::self_fade)
} else {
1.0
},
);
match current {
Some(mut c) => {
if c.0 != alpha {
c.0 = alpha;
}
}
None if alpha >= 1.0 => {}
None => {
commands.entity(entity).insert(ModelAlpha(alpha));
}
}
}
}
/// The camera-to-target span (yd) over which the **player's own** avatar fades from hidden (camera near)
/// to opaque (camera out) as you zoom into first-person. VERIFIED from `WoW.exe` 5875 (`0x8089b0`; the
/// self-model transparency setter `0x5b7bb0`, recorded in wow-re `system/ui/scratch/follow-camera.md`).
@@ -729,6 +826,59 @@ mod tests {
}
}
/// **The login symptom, at its cause** (decision 0827): a unit whose appear-fade has not been
/// armed yet is not being shown at all, so its render alpha is **0** — and the item sparkle
/// that reads this number is therefore invisible too, instead of burning at full strength in
/// front of a body that hasn't faded in. Then the ramp: the same cubic the mesh parts run, so
/// the pauldron's glow and the pauldron arrive together rather than 2 s apart.
#[test]
fn a_units_render_alpha_is_zero_until_it_is_shown_then_rides_the_same_cubic() {
assert_eq!(
model_render_alpha(
10.0,
Some(UnitAppearFade::Pending { since: 9.0 }),
None,
1.0
),
0.0,
"pending: the unit is not on screen, and neither are its effects"
);
let live =
|now| model_render_alpha(now, Some(UnitAppearFade::Live { started: 10.0 }), None, 1.0);
assert_eq!(live(10.0), 0.0, "the ramp starts at nothing");
assert!(
(live(11.0) - fade_alpha(0.0, 1.0, 0.5)).abs() < 1e-6,
"mid-ramp is the mesh parts' own cubic, not a second curve"
);
assert_eq!(live(12.0), 1.0, "…and latches opaque at the duration");
assert_eq!(
model_render_alpha(10.0, None, None, 1.0),
1.0,
"no fade: opaque"
);
}
/// The other two writers of the same slot compose as a PRODUCT — the reference multiplies the
/// transition alpha into `+0x180` rather than keeping a second channel. The self-avatar feather
/// is what takes a held torch's flame out of your face in first person (ledger F05).
#[test]
fn the_render_alpha_multiplies_its_writers() {
// Zooming to first person: the body is opaque-by-appear but the feather is 0.
assert_eq!(model_render_alpha(20.0, None, None, 0.0), 0.0);
assert!((model_render_alpha(20.0, None, None, 0.5) - 0.5).abs() < 1e-6);
// Streaming out: the despawn ramp eases the same channel to nothing.
assert_eq!(model_render_alpha(10.0, None, Some(10.0), 1.0), 1.0);
assert_eq!(model_render_alpha(12.0, None, Some(10.0), 1.0), 0.0);
// Both at once stay a product, and the result can never leave [0, 1].
let both = model_render_alpha(
11.0,
Some(UnitAppearFade::Live { started: 10.0 }),
Some(10.0),
0.5,
);
assert!((0.0..=1.0).contains(&both) && both > 0.0);
}
#[test]
fn unit_fade_retires_after_the_appear_duration() {
// The join marker itself doesn't retire (that's `retire_unit_appear_fade`, an ECS system), but
+17
View File
@@ -141,6 +141,11 @@ pub(super) fn apply_net_updates(
// The pending logout/quit (decision 0674): the server's response and cancel-ack land
// here, and `crate::ui_logout` turns them into the countdown dialog.
ResMut<crate::ui_logout::LogoutState>,
// The shared AreaTable catalog — the exploration arm's area-id → name resolve
// (decision 0828) — and the race-keyed discovery-jingle catalog (decision 0829).
// Either absent if its DBC failed to load.
Option<Res<crate::area::AreaTableRes>>,
Option<Res<crate::sound::ExplorationSounds>>,
),
),
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
@@ -269,6 +274,8 @@ pub(super) fn apply_net_updates(
mut duel,
mut social,
mut logout,
area_table,
exploration_sounds,
),
) = caches;
let (
@@ -891,6 +898,16 @@ pub(super) fn apply_net_updates(
SessionEvent::XpGain(x) => {
combat_log::xp_gain(x, &index, &self_guid, &mut audio.7, &mut chat_log)
}
SessionEvent::ExplorationXp(x) => combat_log::exploration_xp(
x,
area_table.as_deref(),
exploration_sounds.as_deref(),
&index,
&self_guid,
&stores,
&mut audio.0,
&mut chat_log,
),
SessionEvent::LevelUp(l) => combat_log::level_up(l, &mut chat_log),
SessionEvent::SpellStart {
caster,
+56 -2
View File
@@ -17,8 +17,8 @@
use bevy::prelude::*;
use benilla_protocol::messages::{
DamageShield, LevelUpInfo, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellEnergizeLog,
SpellHealLog, SpellLogMiss, XpGain,
DamageShield, ExplorationXp, LevelUpInfo, PeriodicAuraLog, PeriodicTick, SpellDamageLog,
SpellEnergizeLog, SpellHealLog, SpellLogMiss, XpGain,
};
use crate::combat_text::{damage_color, miss_word, spell_text, CombatTextSpawn, DamageSource};
@@ -588,6 +588,60 @@ pub(super) fn xp_gain(
chat_log.push_xp_gain(&x);
}
/// `SMSG_EXPLORATION_EXPERIENCE` → the discovery announcement (decision 0828; surfaces and
/// sound byte-verified by the 0829 RE, wow-re `system/net/net.md`). Three legs, mirroring the
/// real handler's case body `[0x5e41d2, 0x5e42bb]`:
/// - the ERR_ZONE_EXPLORED toast + (xp > 0) the ERR_ZONE_EXPLORED_XP chat line, queued via
/// [`ChatLog::push_exploration`] — the area name is the packet's `AreaTable.dbc` row id
/// resolved through the shared catalog; a miss (no catalog, or an id the DBC doesn't know)
/// shows no message, faithfully;
/// - the race-keyed discovery jingle (`ChrRaces.dbc` col 3 → SoundEntries), played
/// **unconditionally** — even when the area id resolves to nothing — through the same 2D-kit
/// path as `SMSG_PLAY_SOUND`.
///
/// No floating text (the XP itself still arrives via the non-kill `SMSG_LOG_XPGAIN`).
#[allow(clippy::too_many_arguments)] // one arm of the dispatch — its param list IS its input set
pub(super) fn exploration_xp(
x: ExplorationXp,
area_table: Option<&crate::area::AreaTableRes>,
sounds: Option<&crate::sound::ExplorationSounds>,
index: &GuidIndex,
self_guid: &SelfGuid,
stores: &Query<&mut ObjectStore>,
sound_out: &mut MessageWriter<super::super::ServerSoundMessage>,
chat_log: &mut ChatLog,
) {
let race = self_guid
.0
.as_ref()
.and_then(|g| index.0.get(g))
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.unit_race());
let kit = race.and_then(|r| sounds.and_then(|s| s.0.kit(u32::from(r))));
if let Some(kit) = kit {
sound_out.write(super::super::ServerSoundMessage {
kind: super::super::ServerSoundKind::Sound2d,
sound_id: kit,
source: None,
});
}
match area_table.and_then(|t| t.0.name(x.area_id)) {
Some(name) => {
info!(
"exploration: discovered {name} (area {}, {} xp, jingle kit {})",
x.area_id,
x.xp,
kit.unwrap_or(0)
);
chat_log.push_exploration(name, x.xp);
}
None => warn!(
"exploration: discovered area {} has no AreaTable name — no message (sound only)",
x.area_id
),
}
}
/// `SMSG_LEVELUP_INFO` → the ding's chat lines (decision 0304). The talent-count arg is not on the
/// wire — the client computes `(newLevel >= 10) ? 1 : 0` (byte-verified `0x5e407c`, wow-re
/// levelup-ding.md — the 0305 fold-back), exactly this. The ding's VISUAL is deliberately absent
+91 -15
View File
@@ -117,8 +117,12 @@ pub struct ParticleEmitter {
/// moving prop) and **drains** once the owner is gone. `None` for terrain doodads, which carry
/// a fixed placement and despawn with their placement instead.
owner: Option<Entity>,
/// The owner died (missile impacted, effect reaped): emission stops, the pool lives out its
/// lifespans at the frozen placement, and the emitter despawns itself when empty — live
/// What losing [`Self::owner`] means for this emitter — the spawn site's call, because only it
/// knows whether the owner entity going away is a MODEL BEING DESTROYED or an effect ending
/// (see [`OwnerLoss`]).
on_owner_loss: OwnerLoss,
/// The owner died and this emitter is [`OwnerLoss::Drain`]: emission stops, the pool lives out
/// its lifespans at the frozen placement, and the emitter despawns itself when empty — live
/// particles finish instead of popping. Byte ground (wow-re
/// `ribbon-basis-emitter-lifecycle`, the 0202 dispatch's fold-back): the reference frees
/// emitters synchronously at the model dtor, its fade coming from the model staying alive
@@ -137,6 +141,16 @@ pub struct ParticleEmitter {
/// The live attach rotation (identity when [`Self::attach`] is `None` or gone) — refreshed
/// each frame before births/draw.
attach_rot: Quat,
/// The model instance whose [`crate::model_fade::ModelAlpha`] this cloud is multiplied by —
/// the reference's `emitter+0x1a8`, a per-frame copy of that model's `+0x19c` (`0x718960`
/// @`0x719073`), folded into each particle's ALPHA by the over-life sampler (`0x7b9b10`
/// @`0x7b9b42`; wow-re `part-scene-multipliers.md` §4's REFUTED negative + `part-additive-
/// combine.md` §6.1). For an ATTACHED model — a held item, a helm, a pauldron — it is the
/// WEARER's, because an attached model inherits its parent's computed alpha (decision 0827).
/// `None` ⇒ 1.0: a placed doodad instead multiplies its own distance fade ([`EmitterFade`]).
alpha_src: Option<Entity>,
/// This frame's value of that alpha (1.0 until read).
alpha: f32,
/// The **cloud anchor** for anchored mode: the entity whose live translation carries the
/// live pool (the MODEL — a creature root, an effect-instance root, a held item's root),
/// NOT the emitter's bone joint. The joint composes each particle's birth (position and
@@ -245,13 +259,49 @@ impl ChildEmitter {
}
}
/// What becomes of a live pool when its owner entity goes away — the distinction the sim itself
/// cannot make, because from inside it a despawned owner looks the same either way (decision 0826).
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub enum OwnerLoss {
/// The owner MODEL was destroyed: **free the emitter with it**, live particles and all. This
/// is the reference's own rule — a model's emitters are released synchronously at its dtor
/// (wow-re `ribbon-basis-emitter-lifecycle`) — and it is what an equipped item that is
/// replaced/unequipped, or a unit that streams out, must do. Draining instead left the cloud
/// hanging in world space for a whole lifespan while the character walked away from it.
Free,
/// The owner was reaped but the effect should finish: emission stops and the pool lives out
/// its lifespans at the frozen placement (a missile that impacted, a spell effect that
/// ended — the reference's model outliving its emitters while they drain). The default,
/// because it is what every pre-0826 caller got.
#[default]
Drain,
}
/// The live frames an emitter rides — bundled so a call site names each one instead of passing a
/// row of positional `Option<Entity>`s (they are easy to swap and impossible to tell apart at the
/// call). See the matching [`ParticleEmitter`] fields for what each frame does.
#[derive(Clone, Copy, Default)]
pub struct EmitterFrames {
/// The entity whose live transform is this emitter's placement: `(entity, [0; 3])` for a
/// streamed creature/GameObject/held item (the whole model rides), or `(joint, bone_pivot)`
/// for an emitter riding its animated bone (0130 phase 4 — the pivot rebases the model-space
/// `def.position` into the joint's own frame, so an unanimated chain reproduces the static
/// path exactly). `None` for a static doodad (fixed placement, despawned by the placement's
/// own entity list).
pub owner: Option<(Entity, [f32; 3])>,
/// The attachment matrix `A` frame ([`ParticleEmitter::attach`]).
pub attach: Option<Entity>,
/// The cloud anchor — the MODEL, never the bone ([`ParticleEmitter::anchor`]).
pub anchor: Option<Entity>,
/// The MODEL INSTANCE whose render alpha multiplies these particles
/// ([`ParticleEmitter::alpha_src`]).
pub alpha: Option<Entity>,
/// What losing `owner` means ([`OwnerLoss`]).
pub on_owner_loss: OwnerLoss,
}
/// Spawn an emitter entity for one [`ModelEmitter`] at `placement`. `None` if the emitter has no
/// resolved texture (nothing to draw). `owner` ties the emitter to a live entity's frame — it follows
/// that entity's transform each frame and despawns with it: `(entity, [0; 3])` for a streamed
/// creature/GameObject (the whole model rides), or `(joint, bone_pivot)` for a doodad emitter riding
/// its animated bone (0130 phase 4 — the pivot rebases the model-space `def.position` into the
/// joint's own frame, so an unanimated chain reproduces the static path exactly). Pass `None` for a
/// static doodad (fixed placement, despawned by the placement's own entity list).
/// resolved texture (nothing to draw); `frames` ties it to the live entities it rides.
impl ParticleEmitter {
/// Live particle count — read by the perf probe ([`crate::capture`]).
pub(crate) fn live(&self) -> usize {
@@ -281,6 +331,22 @@ impl ParticleEmitter {
owner_last_bias(self.owner_reach)
}
/// Is this emitter in the frame's draw set — i.e. did the draw-set gate let it tick and push
/// quads this frame? The instrument-side spelling of [`Self::gated`], read by the particle
/// census probe. (It read the entity's `Visibility` until slice P2 (0733) moved the family onto
/// the shared stream and emitter entities stopped carrying one — after which the probe's query
/// matched NOTHING and every column it printed, including B39's `drawn_beyond_wall` guard, was
/// a vacuous zero.)
pub(crate) fn drawn(&self) -> bool {
!self.gated
}
/// This frame's MODEL render alpha ([`Self::alpha_src`]) — read by the MODEL-particle lane,
/// whose instances carry their alpha in a `MeshTag` rather than a vertex colour.
pub(super) fn render_alpha(&self) -> f32 {
self.alpha
}
/// The cloud's live world anchor — the census probe's fallback distance subject for an emitter
/// with no [`EmitterFade`] (entity-owned: creatures, GameObjects, spell kits). A faded
/// emitter's distance is measured to its OWNER's sphere instead, because that sphere is what
@@ -376,6 +442,16 @@ pub struct EmitterFade {
}
impl EmitterFade {
/// This owner's distance-fade ALPHA (not the cutoff): the reference writes it into the
/// doodad's `CM2Model+0x180`, so it multiplies that model's particles exactly as it does its
/// batches (`FUN_00683f80` → `+0x180` → `+0x19c` → `emitter+0x1a8`, decision 0827). The
/// [`Self::in_draw_set`] gate is the same curve's zero crossing — this is the feather before
/// it, which is why a small prop's flame now thins out over its band instead of cutting.
pub fn distance_alpha(&self, cam_pos: Vec3) -> f32 {
let (dx, dz) = (self.center.x - cam_pos.x, self.center.z - cam_pos.z);
crate::model_fade::doodad_fade_alpha(self.radius, (dx * dx + dz * dz).sqrt())
}
/// The **draw-set admission rule** in one place: is the owner doodad in the frame's scene
/// worklist, and therefore does its emitter tick and draw this frame?
///
@@ -428,14 +504,11 @@ impl EmitterFade {
}
}
#[allow(clippy::too_many_arguments)] // the spawn's full wiring: the owner/attach/anchor frames
pub fn spawn_emitter(
commands: &mut Commands,
emitter: &ModelEmitter,
placement: Transform,
owner: Option<(Entity, [f32; 3])>,
attach: Option<Entity>,
anchor: Option<Entity>,
frames: EmitterFrames,
clock: EmitClock,
) -> Option<Entity> {
// Perf-bisect kill-switch: $WOW_NO_PARTICLES spawns no emitters at all.
@@ -451,7 +524,7 @@ pub fn spawn_emitter(
None => return None,
};
let mut def = emitter.def.clone();
let owner = owner.map(|(entity, pivot)| {
let owner = frames.owner.map(|(entity, pivot)| {
// Rebase the model-space emitter origin into the owner frame (bone-local for a joint owner;
// pivot = 0 leaves it model-space for a whole-model owner). Same raw WoW axes throughout —
// the sim composes `def.position` with the owner's live transform at each birth.
@@ -491,10 +564,13 @@ pub fn spawn_emitter(
def,
placement,
owner,
on_owner_loss: frames.on_owner_loss,
draining: false,
attach,
attach: frames.attach,
attach_rot: Quat::IDENTITY,
anchor,
alpha_src: frames.alpha,
alpha: 1.0,
anchor: frames.anchor,
anchor_pos: placement.translation,
particles: Vec::new(),
accumulator: 0.0,
+4 -1
View File
@@ -148,7 +148,10 @@ pub(super) fn update_model_particles(
let u = (p.age / emitter.def.lifespan).clamp(0.0, 1.0);
// 3-D model particles carry no texture atlas — only colour and size reach them.
let ol = emitter.def.over_life.sample(u);
let (rgba, size) = (ol.color, ol.size);
let (mut rgba, size) = (ol.color, ol.size);
// The owning model's render alpha, same fold as the quad lane (decision 0827) — here
// into the instance's `MeshTag` alpha, which is where a 3-D particle carries it.
rgba[3] *= emitter.render_alpha();
let tf = if anchored {
Transform {
translation: emitter.anchor_pos + emitter.attach_rot * p.pos,
+60 -2
View File
@@ -64,6 +64,9 @@ pub(super) struct DrawFrame {
pub anchored: bool,
pub anchor: Vec3,
pub attach_rot: Quat,
/// The owning MODEL's render alpha, folded into every particle's alpha channel — the
/// reference's `emitter+0x1a8` (decision 0827). 1.0 for a model that isn't fading.
pub alpha: f32,
}
/// One particle's world-space quad centre — THE point [`expand_quads`] rasterizes around, factored
@@ -165,7 +168,13 @@ pub(super) fn expand_quads(
}
let u_age = (p.age / def.lifespan).clamp(0.0, 1.0);
let ol = def.over_life.sample(u_age);
let (rgba, size) = (ol.color, ol.size);
let (mut rgba, size) = (ol.color, ol.size);
// The MODEL's render alpha, folded into the ALPHA channel only — exactly where and how the
// reference does it: the sampler `0x7b9b10` takes it as its second argument and applies it
// at `0x7b9b42 fmul` to the alpha byte alone, RGB untouched (decision 0827). So a unit
// fading in fades its effects in with it, and a first-person avatar's torch stops burning
// in your face.
rgba[3] *= frame.alpha;
// RAW authored RGB — the gamma-space decode happens ONCE, in the lane's fragment
// (`wow_effect.wgsl`, decision 0152), where it also covers the texture term that a
// CPU-side vertex linearisation (0150) could not reach. Alpha is a blend weight — raw
@@ -285,7 +294,8 @@ pub(super) fn expand_quads(
#[cfg(test)]
mod tests {
use super::spin_angle;
use super::{expand_quads, spin_angle, CamBasis, DrawFrame, Particle};
use bevy::prelude::{Quat, Transform, Vec3};
/// The `0x7b2dda` negate: only a NEGATIVE angle on a bit-5 particle flips — a negative-spin
/// emitter counter-rotates exactly its bit-5 half, a positive-spin one never splits.
@@ -301,4 +311,52 @@ mod tests {
assert_eq!(spin_angle(3.0, 0.5, 0x1f), 1.5);
assert_eq!(spin_angle(0.0, 0.5, 0xff), 0.0);
}
/// **The model's render alpha reaches its particles, and ONLY their alpha** (decision 0827).
/// The reference folds `emitter+0x1a8` (a per-frame copy of the model's `CM2Model+0x19c`)
/// inside the over-life sampler `0x7b9b10`, at `0x7b9b42 fmul` — which writes the **alpha byte
/// alone** (`mov [eax+3],cl`); R/G/B never see it (`part-additive-combine.md` §6.1, the
/// refutation of `part-scene-multipliers.md` §4's old negative). A fold into RGB would look
/// almost right on an additive quad and be wrong on every alpha-blended one, so the channel
/// split is what this pins.
#[test]
fn the_models_render_alpha_scales_particle_alpha_and_nothing_else() {
let mut def = crate::particles::tests::plain_def();
def.over_life.color = [[0.8, 0.4, 0.2, 0.5]; 3];
let pool = [Particle {
pos: Vec3::ZERO,
vel: Vec3::ZERO,
age: 0.0,
phase: 0,
fresh: false,
quat: Quat::IDENTITY,
angvel: Vec3::ZERO,
}];
let cam = CamBasis {
right: Vec3::X,
up: Vec3::Y,
};
let shoot = |alpha| {
let frame = DrawFrame {
anchored: true,
anchor: Vec3::ZERO,
attach_rot: Quat::IDENTITY,
alpha,
};
let mut out = Vec::new();
expand_quads(&def, &pool, &frame, &Transform::IDENTITY, &cam, &mut out);
out[0].color
};
let opaque = shoot(1.0);
assert_eq!(opaque, [0.8, 0.4, 0.2, 0.5], "no fade: the authored ramp");
let half = shoot(0.5);
assert_eq!(
[half[0], half[1], half[2]],
[0.8, 0.4, 0.2],
"RGB is untouched by the model alpha"
);
assert!((half[3] - 0.25).abs() < 1e-6, "alpha halves: {half:?}");
assert_eq!(shoot(0.0)[3], 0.0, "an invisible model draws nothing");
}
}
+70 -14
View File
@@ -11,7 +11,7 @@ use super::buffer::{EffectDrawSpec, EffectFog, EffectLightOverride, EffectQuads}
use super::quads::{expand_quads, CamBasis, DrawFrame};
use super::{
accumulate_emission, emit_local, next_u32, rand01, rand_s11, ChildDraw, ChildEmitter,
EmitterFade, Particle, ParticleEmitter, ParticleTuning, MAX_PARTICLES,
EmitterFade, OwnerLoss, Particle, ParticleEmitter, ParticleTuning, MAX_PARTICLES,
};
/// The emitter-constant inputs of one integrator step ([`integrate_particle`]).
@@ -162,19 +162,26 @@ fn drive_child(
}
}
/// The **draw-set gate's** scene inputs, bundled: the far-clip wall the gate bounds emitters at
/// (0678) and the exterior-window test a WMO interior applies to everything outside it (0786),
/// with the camera's own room as its one exemption. One `SystemParam` because they are read
/// together, at one place, and Bevy caps a system at 16 parameters.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct SceneGates<'w> {
view: Res<'w, crate::view::ViewDistance>,
exterior_windows: Res<'w, crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
}
/// Per-frame: emit, integrate, and expand each emitter's pool into the shared effect-quad
/// stream ([`super::buffer::EffectQuads`]).
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
pub(super) fn simulate_particles(
time: Res<Time>,
tuning: Res<ParticleTuning>,
// The far-clip wall — the emitter draw-set gate's depth bound (see the gate below).
view: Res<crate::view::ViewDistance>,
// …and the gate's exterior-window term (0786): from inside a WMO, a doodad outside is not in
// the frame's worklist, so its emitter must not tick. `camera_claim` carries the one exemption —
// a prop of the building the camera is standing in.
exterior_windows: Res<crate::wmo_portal::ExteriorWindows>,
camera_claim: Res<crate::wmo_portal::CameraInteriorClaim>,
// The draw-set gate's scene inputs — the far-clip wall (0678) and the exterior window test
// (0786); see [`SceneGates`].
gates: SceneGates,
mut commands: Commands,
cam: Query<(Entity, &GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
@@ -195,6 +202,8 @@ pub(super) fn simulate_particles(
// The ground-snap probe (file 0x2000 births): terrain + WMO/doodad geometry, the walking
// collision audience.
spatial: SpatialQuery,
// The per-model render alpha an entity-owned cloud is multiplied by (decision 0827).
model_alphas: Query<&crate::model_fade::ModelAlpha>,
// `Without<WorldCamera>`: the `&mut GlobalTransform` must be provably disjoint from the
// camera read above too (an emitter never rides the camera entity).
mut emitters: Query<
@@ -248,9 +257,11 @@ pub(super) fn simulate_particles(
let cam_fwd = Vec3::from(cam_tf.forward());
// The exterior gate, built once for the whole emitter walk — the same value the model
// visibility authority and `exterior_cull` ask (0784/0786: one spelling of the window test).
let exterior_gate =
crate::exterior_cull::ExteriorGate::build(&exterior_windows, Some((cam_tf, projection)));
let camera_instance = camera_claim.0.map(|c| c.room.instance);
let exterior_gate = crate::exterior_cull::ExteriorGate::build(
&gates.exterior_windows,
Some((cam_tf, projection)),
);
let camera_instance = gates.camera_claim.0.map(|c| c.room.instance);
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = super::depthdump::frame(time.elapsed_secs(), &mut dump_count);
@@ -296,7 +307,7 @@ pub(super) fn simulate_particles(
let in_set = f.in_draw_set(
cam_pos,
cam_fwd,
view.farclip,
gates.view.farclip,
// Lateral planes only (`intersect_far = false`) — the depth bound is the farclip
// term inside `in_draw_set`, deliberately; see `view::within_farclip`.
frustum.intersects_sphere(
@@ -357,7 +368,7 @@ pub(super) fn simulate_particles(
None => Some(emitter.anchor_pos),
};
if let Some(at) = subject {
if !crate::view::within_farclip(view.farclip, cam_pos, cam_fwd, at, 0.0) {
if !crate::view::within_farclip(gates.view.farclip, cam_pos, cam_fwd, at, 0.0) {
if !emitter.gated {
emitter.gated = true;
for slot in &emitter.model_instances {
@@ -377,9 +388,12 @@ pub(super) fn simulate_particles(
def,
placement,
owner,
on_owner_loss,
draining,
attach,
attach_rot,
alpha_src,
alpha,
anchor,
anchor_pos,
particles,
@@ -416,7 +430,38 @@ pub(super) fn simulate_particles(
Ok(gt) => *placement = gt.compute_transform(),
Err(_) => {
*owner = None;
*draining = true;
match *on_owner_loss {
// The owner MODEL is gone, so its effects go with it — the reference frees
// a model's emitters at its dtor (decision 0826). Nothing to drain: an
// unequipped torch's flame does not stay behind in the air.
OwnerLoss::Free => {
for slot in model_instances.iter() {
for (e, _) in &slot.meshes {
commands.entity(*e).despawn();
}
}
commands.entity(entity).despawn();
continue;
}
OwnerLoss::Drain => {
*draining = true;
// The ghost-cloud event, named so a run can count it: this pool now
// lives out its lifespans FROZEN in world space. Right for an effect
// that ended in place (a missile impact), and the reason `Free` exists
// for everything that is a model teardown.
if !particles.is_empty() {
debug!(
"fx orphan: emitter {entity} ({}) lost its owner with {} \
live particles frozen at {:?}",
texture
.path()
.map_or_else(|| "<no path>".into(), |p| p.to_string()),
particles.len(),
*anchor_pos
);
}
}
}
}
}
}
@@ -437,6 +482,16 @@ pub(super) fn simulate_particles(
*attach_rot = rot;
}
}
// The MODEL's render alpha for this frame (decision 0827 — the reference's per-frame
// `emitter+0x1a8 = Model+0x19c` copy at `0x718960` @`0x719073`). Two disjoint sources, the
// same slot the reference writes both through: an entity-owned cloud takes its model
// instance's [`crate::model_fade::ModelAlpha`] (an item's is its WEARER's — an attached
// model inherits the parent's), and a placed doodad's takes its own distance fade, whose
// cutoff the draw-set gate above already applies as a hard stop.
*alpha = alpha_src
.and_then(|e| model_alphas.get(e).ok())
.map_or(1.0, |a| a.0)
* fade.map_or(1.0, |f| f.distance_alpha(cam_pos));
let attach_inv = attach_rot.inverse();
// The cloud anchor (see the field doc): the model's live translation, or the last-known
// one while the pool drains. A whole-model owner keeps anchor == owner — identical math.
@@ -743,6 +798,7 @@ pub(super) fn simulate_particles(
anchored,
anchor,
attach_rot: *attach_rot,
alpha: *alpha,
};
let cam = CamBasis {
right: e_right,
+163
View File
@@ -80,18 +80,181 @@ impl Plugin for PerfPlugin {
))
.init_resource::<FrameStats>()
.init_resource::<PerfHud>()
.init_resource::<StreamActivity>()
.insert_resource(StreamTrace {
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
frame: 0,
log_until: 0,
prev_cpu_secs: None,
})
.add_systems(
Update,
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its Ctrl+Cmd+P
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
(toggle_hud, sample_frame_time),
)
// `Last`, so a row carries everything this frame's Stream chain did — and the reset runs
// whether or not anything is tracing, or the counters would accumulate forever.
.add_systems(Last, trace_stream)
.add_systems(EguiPrimaryContextPass, perf_hud_ui);
#[cfg(target_os = "macos")]
stall_sample::plugin(app);
}
}
/// Per-frame terrain-streamer activity, bumped by the `WorldStage::Stream` chain as it works
/// (a handful of integer adds + three self-timers — maintained unconditionally, zeroed by
/// [`trace_stream`] every frame). Exists because B181's symptom — a frame spike on every ADT
/// tile-boundary crossing — is keyed to *what the streamer did that frame*, which no 1 Hz journal
/// row can attribute: the spike frame needs its own row saying what was dropped, requested and
/// spawned beside what the frame cost.
#[derive(Resource, Default)]
pub(crate) struct StreamActivity {
/// Tiles whose owned entities (terrain root + cells, liquid, clutter) were despawned.
pub(crate) tiles_dropped: u32,
/// Shared placements whose refcount hit zero (their entities despawned with them).
pub(crate) placements_dropped: u32,
/// Entity handles despawned with those placements (submeshes, colliders, lights, props).
pub(crate) placement_entities_dropped: u32,
/// New ADT asset requests fired.
pub(crate) tiles_requested: u32,
/// Loaded tiles spawned (terrain mesh + placements registered + liquid + clutter).
pub(crate) tiles_spawned: u32,
/// Placements (or WMO props) whose model landed and spawned.
pub(crate) placements_spawned: u32,
/// Off-thread colliders attached.
pub(crate) colliders_attached: u32,
/// Self-time of `stream_terrain` / `spawn_loaded_placements` / `finish_colliders` (ms).
pub(crate) stream_ms: f32,
pub(crate) spawn_ms: f32,
pub(crate) collider_ms: f32,
}
impl StreamActivity {
/// Did the streamer *do* anything this frame? (The self-timers don't count: the desired-set
/// scan runs every frame and would make every row an "event".)
fn any_event(&self) -> bool {
self.tiles_dropped
+ self.placements_dropped
+ self.tiles_requested
+ self.tiles_spawned
+ self.placements_spawned
+ self.colliders_attached
> 0
}
}
/// A frame past this is logged even with no streamer activity — catches a spike whose cost lands
/// outside the Stream chain (command application, render extraction, asset frees frames later).
const TRACE_FRAME_MS: f32 = 25.0;
/// Frames still logged after the last streamer event: the despawn commands apply after the system,
/// the asset frees land when the last handle drops, and the render world reacts a frame later —
/// the tail is where the cost shows, so the window must outlive the event.
const TRACE_TAIL_FRAMES: u64 = 10;
/// The STREAM TRACE (`WOW_STREAM_TRACE=<csv path>`) — B181's instrument. One row per frame in
/// which the terrain streamer did anything (plus a [`TRACE_TAIL_FRAMES`] tail, plus any frame over
/// [`TRACE_FRAME_MS`]): what was dropped/requested/spawned, the Stream chain's own self-times, and
/// the frame's wall delta beside it. **The off-by-one to remember reading it: `delta_ms` on a row
/// is the interval that *ended* at this frame's start, so frame N's cost appears in row N+1.**
#[derive(Resource)]
struct StreamTrace {
path: String,
frame: u64,
log_until: u64,
/// [`process_cpu_secs`] at the previous frame — the row's `cpu_ms` is this frame's process-CPU
/// delta (user+system, all threads): the load-robust cost meter beside the wall delta, because
/// on this machine wall frame time moves with whoever else is compiling (0711; a parallel
/// build polluted this instrument's first A/B).
prev_cpu_secs: Option<f64>,
}
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,spawn_ms,collider_ms,\
tiles_dropped,pl_dropped,pl_ents_dropped,tiles_req,\
tiles_spawned,pl_spawned,attached,adt_freed,meshes_freed,\
images_freed\n";
#[allow(clippy::too_many_arguments)]
fn trace_stream(
mut trace: ResMut<StreamTrace>,
mut activity: ResMut<StreamActivity>,
time: Res<Time<Real>>,
entities: Query<()>,
mut adt_events: MessageReader<bevy::asset::AssetEvent<benilla_assets::AdtTile>>,
mut mesh_events: MessageReader<bevy::asset::AssetEvent<Mesh>>,
mut image_events: MessageReader<bevy::asset::AssetEvent<bevy::image::Image>>,
) {
// Taken every frame — the counters are per-frame by contract, tracing or not.
let a = std::mem::take(&mut *activity);
trace.frame += 1;
if trace.path.is_empty() {
return;
}
let cpu_now = process_cpu_secs();
let cpu_ms = match (trace.prev_cpu_secs, cpu_now) {
(Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0),
_ => String::new(),
};
trace.prev_cpu_secs = cpu_now;
let freed = |n: usize| n as u32;
let removed_adt = freed(
adt_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
.count(),
);
let removed_mesh = freed(
mesh_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
.count(),
);
let removed_img = freed(
image_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
.count(),
);
let delta_ms = time.delta_secs() * 1000.0;
if a.any_event() || removed_adt > 0 {
trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
}
if !(a.any_event()
|| removed_adt > 0
|| delta_ms > TRACE_FRAME_MS
|| trace.frame <= trace.log_until)
{
return;
}
if trace.frame == 1 || !std::path::Path::new(&trace.path).exists() {
let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
}
let line = format!(
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img}\n",
trace.frame,
time.elapsed_secs(),
entities.iter().len(),
a.stream_ms,
a.spawn_ms,
a.collider_ms,
a.tiles_dropped,
a.placements_dropped,
a.placement_entities_dropped,
a.tiles_requested,
a.tiles_spawned,
a.placements_spawned,
a.colliders_attached,
);
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&trace.path)
{
let _ = f.write_all(line.as_bytes());
}
}
/// Rolling window of recent **real** frame durations (ms) + the derived hitch metrics.
#[derive(Resource)]
struct FrameStats {
+10 -3
View File
@@ -168,9 +168,16 @@ pub(super) fn spawn_booth_effects(
commands,
em,
Transform::IDENTITY,
Some(owner),
Some(host), // an attached model — its fan swings with the item
Some(host), // the cloud anchors at the MODEL; the bone composes births only
crate::particles::EmitterFrames {
owner: Some(owner),
attach: Some(host), // an attached model — its fan swings with the item
anchor: Some(host), // the cloud anchors at the MODEL; bones compose births only
// A booth rider's host is torn down with the bake it belongs to.
on_owner_loss: crate::particles::OwnerLoss::Free,
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
},
crate::particles::EmitClock::Pinned, // an item's effects loop forever
) else {
continue;
+10 -3
View File
@@ -669,9 +669,16 @@ pub(super) fn sync_glue_scene(
&mut commands,
em,
Transform::IDENTITY,
Some(owner),
None, // a backdrop scene is never an attached model
Some(scene.root),
crate::particles::EmitterFrames {
owner: Some(owner),
attach: None, // a backdrop scene is never an attached model
anchor: Some(scene.root),
// The scene root is torn down and rebuilt as a whole.
on_owner_loss: crate::particles::OwnerLoss::Free,
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
},
// A glue scene loops its one authored clip forever — the doodad law.
crate::particles::EmitClock::Pinned,
) {
+26 -1
View File
@@ -58,6 +58,15 @@ pub struct RibbonTrail {
/// active flag before destroy, or hard-cuts at animation end, is the one OPEN half
/// (CEffect-side, flagged in decision 0206).
owner: Option<Entity>,
/// The MODEL INSTANCE whose [`crate::model_fade::ModelAlpha`] decides whether this trail is
/// drawn at all (decision 0827). The reference's ribbon render leg reads the owning model's
/// render alpha (`block+0x3c × Model+0x19c`) and **drops the draw** below a threshold (wow-re
/// `ribbon-emitter-spec.md` §5) — so an invisible model has no streamer, which is what a
/// first-person avatar's enchant trail needs (ledger F05). Only the drop is implemented: the
/// note does not say the model alpha scales the strip's vertex colour the way it does a
/// particle's, and inventing a ramp on top of a gate would be building past the evidence.
/// `None` ⇒ always drawn (a placed prop, an effect instance).
alpha_src: Option<Entity>,
/// Committed edges, newest at the back. The live head (the current node) is appended at
/// render time only, so the trail always connects to the emitter between commits.
edges: VecDeque<Edge>,
@@ -95,7 +104,7 @@ impl RibbonTrail {
/// model-local [`ModelRibbon::owner_reach`] takes it to reach world yards, which is what the
/// draw-order rung is measured in. `None` if the trail has no resolved texture, degenerate
/// emission, or is dark in that sequence.
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)] // the spawn's full wiring, `alpha_src` included
pub fn spawn_ribbon(
commands: &mut Commands,
ribbon: &ModelRibbon,
@@ -103,6 +112,7 @@ pub fn spawn_ribbon(
use_pivot: bool,
owner_scale: f32,
current_anim: Option<u16>,
alpha_src: Option<Entity>,
) -> Option<Entity> {
// Perf-bisect kill-switch: $WOW_NO_PARTICLES also spawns no ribbons (one switch, whole family).
if std::env::var_os("WOW_NO_PARTICLES").is_some() {
@@ -153,6 +163,7 @@ pub fn spawn_ribbon(
local_offset: wow_to_bevy(local),
def,
owner: Some(owner),
alpha_src,
edges: VecDeque::new(),
accumulator: 0.0,
age: 0.0,
@@ -169,6 +180,7 @@ pub fn spawn_ribbon(
/// Per-frame: place the node from the owner's live transform, commit/expire edges, sag by
/// gravity, and write the strip into the shared effect-quad stream.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(crate) fn simulate_ribbons(
time: Res<Time>,
mut commands: Commands,
@@ -177,6 +189,8 @@ pub(crate) fn simulate_ribbons(
transforms: Query<&GlobalTransform, Without<RibbonTrail>>,
images: Res<Assets<Image>>,
mut quads: ResMut<EffectQuads>,
// The owning model's render alpha — the trail's draw gate (decision 0827).
model_alphas: Query<&crate::model_fade::ModelAlpha>,
// Trails belong to the world lane (no booth ribbons; a booth-parked owner's strip is eaten
// by the shader's farclip wall, exactly as on the material path).
world_cam: Query<Entity, With<WorldCamera>>,
@@ -197,6 +211,7 @@ pub(crate) fn simulate_ribbons(
def,
local_offset,
owner,
alpha_src,
edges,
accumulator,
age,
@@ -270,6 +285,16 @@ pub(crate) fn simulate_ribbons(
if !images.contains(&*texture) {
continue;
}
// An invisible MODEL has no streamer: the reference's ribbon render leg reads the owning
// model's render alpha and drops the draw below a threshold (decision 0827). This is what
// takes your own weapon's enchant trail out of your face in first person, and keeps a
// not-yet-shown unit's trail off the screen while its body is still at alpha 0.
if alpha_src
.and_then(|e| model_alphas.get(e).ok())
.is_some_and(|a| a.0 <= 1e-3)
{
continue;
}
let n = edges.len() + usize::from(head.is_some());
if n < 2 {
continue;
+1
View File
@@ -41,6 +41,7 @@ pub(crate) use glue::GlueSound;
pub(crate) use greeting::NpcGreetingRequest;
pub(crate) use mixer::Mixer;
pub(crate) use ui::{AutoEquipSound, LootPickupSound};
pub(crate) use zone::ExplorationSounds;
/// Player-facing audio config — always-on, player-faithful defaults (decision 0026: no
/// gameplay→dev coupling; the debug panel edits this, it doesn't own it).
+20
View File
@@ -52,6 +52,12 @@ use super::{AudioListener, SoundConfig, SoundOutput};
#[derive(Resource)]
pub(crate) struct AreaSounds(pub(crate) AreaSoundCatalog);
/// The race → exploration-jingle catalog (`ChrRaces.dbc` column 3) — the discovery sound
/// `net::apply`'s `SMSG_EXPLORATION_EXPERIENCE` arm plays (decision 0829). Absent when the
/// client data didn't load.
#[derive(Resource)]
pub(crate) struct ExplorationSounds(pub(crate) benilla_formats::ExplorationSoundCatalog);
/// Day iff 05:30 ≤ clock < 21:00 (module docs — the client's hard step, B4-verified).
/// Index into the `[day, night]` DBC pairs.
fn phase(clock: &GameClock) -> usize {
@@ -211,6 +217,20 @@ fn load_area_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
}
Err(e) => warn!("sound: zone-audio catalog failed to load: {e:#}"),
}
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_exploration_sound_catalog(&mut chain)
};
match loaded {
Ok(cat) => {
info!(
"sound: {} races in the exploration-sound catalog",
cat.len()
);
commands.insert_resource(ExplorationSounds(cat));
}
Err(e) => warn!("sound: exploration-sound catalog failed to load: {e:#}"),
}
}
/// The per-frame scheduler: react to area/phase changes, arm the transition fade-stop, run the
+114 -35
View File
@@ -365,6 +365,7 @@ fn stream_terrain(
ResMut<Assets<Mesh>>,
Res<Assets<WdtIndex>>,
Res<Time>,
ResMut<crate::perf::StreamActivity>,
),
liquid_assets: Option<Res<LiquidAssets>>,
clutter: Option<Res<GroundClutter>>,
@@ -383,13 +384,14 @@ fn stream_terrain(
),
mut load_progress: Option<ResMut<WorldLoadProgress>>,
) {
let (mut materials, mut meshes, wdts, time) = asset_stores;
let (mut materials, mut meshes, wdts, time, mut activity) = asset_stores;
let (current_map, map_catalog, roster) = location;
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
// there's nothing to stream against, so idle.
let (Some(shared_light), Some(map_catalog)) = (shared_light, map_catalog) else {
return;
};
let t0 = Instant::now();
let placements = placements.into_inner();
let map_id = focus_map(
current_map.map(|m| m.0),
@@ -412,7 +414,7 @@ fn stream_terrain(
state.map_dir,
state.tiles.len()
);
drop_streamed_world(&mut commands, &mut state, placements);
drop_streamed_world(&mut commands, &mut state, placements, &mut activity);
state.map_dir = Some(dir.clone());
state.wdt = Some(asset_server.load(format!("mpq://World/Maps/{dir}/{dir}.wdt")));
state.wdt_ungated = false;
@@ -488,18 +490,53 @@ fn stream_terrain(
desired.retain(|&(tx, ty)| w.has_tile(tx as u32, ty as u32));
}
// Unload tiles no longer desired: despawn the terrain entity, release the placements, drop the handle.
let stale: Vec<(i32, i32)> = state
.tiles
.keys()
.copied()
.filter(|c| !desired.contains(c))
.collect();
// Unload tiles no longer desired: despawn the terrain entity, release the placements, drop the
// handle. **Budgeted per frame** (B181): dropping the whole trailing row on the crossing frame
// freed ~1300 mesh assets — each tile's 256 chunk cells, CPU copies included — plus the tiles'
// decoded chunks and their placements' models, all in one frame. Measured (`WOW_STREAM_TRACE`,
// Emerald Dream flat-map pin): that free wave is the 2–3-dropped-interval spike on every ADT
// boundary crossing, while this chain's own systems — request, spawn, collider attach, each
// already budgeted — stayed under 3 ms. So the drop lane gets the same treatment: at most
// `unload_budget` tiles release per frame, farthest from the focus first. The un-drained
// remainder just stays resident a few frames longer — behind the direction of travel, and a
// re-cross inside the drain window finds its tile still loaded (no re-decode, no re-spawn).
// A cross-map swap is NOT budgeted (`drop_streamed_world` above): the loading screen covers
// it, and holding a dead map's tiles through a fresh map's IO burst helps nothing.
// **The keep-band** (B181): a tile is requested inside `radius` but released only past
// `radius + 1` — one tile of hysteresis. Without it, a body straddling a tile line cycles a
// whole row per step: the reporter's own minimal repro (`.go` half a yard across, and back)
// re-decoded and re-spawned five tiles each way, and the row's landing — not the streamer's
// own budgeted systems — is the frame spike (a fresh Undercity row lands ~3900 placements
// and ~1300 mesh assets). A band tile stays fully resident, so a re-cross finds it and
// streams NOTHING; the band's dedup entries need no art-sweep accommodation (0793's
// `radius_floor` is about art the streamer will re-fetch — a band tile is already spawned
// and never re-consults the caches).
//
// Ablation switch (`WOW_NO_TILE_DROP=1`): never release stale tiles — a crossing becomes a
// pure asset-ADD event and a re-cross a pure no-op, separating the load lane from the free
// lane when measuring a streaming cost. Residency grows for the life of the run; dev-only.
let unload_budget = cfg.as_ref().map(|c| c.unload_budget).unwrap_or(1);
let keep = radius + 1;
let mut stale: Vec<(i32, i32)> = if std::env::var("WOW_NO_TILE_DROP").is_ok() {
Vec::new()
} else {
state
.tiles
.keys()
.copied()
.filter(|&(tx, ty)| (tx - cx).abs().max((ty - cy).abs()) > keep)
.collect()
};
if unload_budget > 0 && stale.len() > unload_budget {
stale.sort_by_key(|&(tx, ty)| std::cmp::Reverse((tx - cx).abs().max((ty - cy).abs())));
stale.truncate(unload_budget);
}
for c in stale {
if let Some(t) = state.tiles.remove(&c) {
despawn_tile_owned(&mut commands, &t);
activity.tiles_dropped += 1;
for uid in t.placements {
release_placement(&mut commands, placements, uid);
release_placement(&mut commands, placements, uid, &mut activity);
}
}
}
@@ -507,15 +544,38 @@ fn stream_terrain(
// Request newly-desired tiles (the `AssetServer` dedups, so re-requesting a loaded one is
// free) — only once the WDT has answered (or failed into the ungated fallback): a request
// fired before the index lands could probe a tile that doesn't exist.
//
// **Staggered while live** (B181): a whole fresh row requested in one frame decodes in
// parallel and LANDS near-together — ~1300 mesh assets plus the texture arrays hitting the
// render world's prepare in one or two frames, a wave no app-side budget downstream of the
// request can spread. One fresh request per frame, nearest first, staggers the landings for
// the only cost of the window edge (~2 tiles out, in fog) filling over a few frames. World
// entry and teleports stay unstaggered: the loading screen exists to absorb that burst, and
// the settle release waits on exactly these tiles.
if wdt_index.is_some() || state.wdt_ungated {
for &(tx, ty) in &desired {
state.tiles.entry((tx, ty)).or_insert_with(|| TileState {
handle: asset_server.load(format!("mpq://World/Maps/{dir}/{dir}_{tx}_{ty}.adt")),
entity: None,
placements: Vec::new(),
liquid: Vec::new(),
clutter: Vec::new(),
});
let live = player.active && !player.settling && !player.world_stale;
let mut fresh: Vec<(i32, i32)> = desired
.iter()
.copied()
.filter(|c| !state.tiles.contains_key(c))
.collect();
if live && fresh.len() > 1 {
fresh.sort_by_key(|&(tx, ty)| (tx - cx).abs().max((ty - cy).abs()));
fresh.truncate(1);
}
for (tx, ty) in fresh {
activity.tiles_requested += 1;
state.tiles.insert(
(tx, ty),
TileState {
handle: asset_server
.load(format!("mpq://World/Maps/{dir}/{dir}_{tx}_{ty}.adt")),
entity: None,
placements: Vec::new(),
liquid: Vec::new(),
clutter: Vec::new(),
},
);
}
}
@@ -553,19 +613,26 @@ fn stream_terrain(
// every portal window, so it could never be hidden), and it also gives Bevy's own frustum cull
// something smaller than half a kilometre to reject. Despawning the root takes the cells.
let mut tile_ent = commands.spawn((Transform::IDENTITY, Visibility::default()));
tile_ent.with_children(|cells| {
for mesh in &adt.chunk_meshes {
cells.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::IDENTITY, // chunk meshes are already in absolute world coords
// ADT terrain is exterior scene: from inside a WMO a cell draws only through a
// portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see
// `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
}
});
// Ablation switch (`WOW_NO_TILE_CELLS=1`): spawn the tile — asset residency, collider,
// placements, liquid, clutter — without its per-chunk cell entities, isolating the
// render-entity lane from the asset lane when measuring a streaming cost. The B181 carve
// ran on it (a crossing's spike survived zero cells, exonerating the entity lane); the
// `WOW_NO_LIQUID`/`WOW_NO_PARTICLES` family's pattern.
if std::env::var("WOW_NO_TILE_CELLS").is_err() {
tile_ent.with_children(|cells| {
for mesh in &adt.chunk_meshes {
cells.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::IDENTITY, // chunk meshes are already in absolute world coords
// ADT terrain is exterior scene: from inside a WMO a cell draws only through a
// portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see
// `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
}
});
}
if let Some((verts, tris)) = collider_data {
// `GroundDecalSurface`: terrain receives the selection ring (see `crate::collision`).
// `PickOccluder`: terrain clamps the mouse pick (the reference's world trace).
@@ -576,6 +643,7 @@ fn stream_terrain(
));
}
tile.entity = Some(tile_ent.id());
activity.tiles_spawned += 1;
// Register this tile's doodad/WMO placements (deduped + refcounted by uniqueId across tiles).
// The MCSH ground-shade is NOT sampled here: a doodad straddles several tiles and this one may
@@ -719,6 +787,7 @@ fn stream_terrain(
}
}
}
activity.stream_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
/// Register one M2 doodad placement: bump the refcount if it's already known, else load its model and
@@ -791,15 +860,17 @@ fn drop_streamed_world(
commands: &mut Commands,
state: &mut TerrainStreamer,
placements: &mut Placements,
activity: &mut crate::perf::StreamActivity,
) {
for ((_tx, _ty), t) in state.tiles.drain() {
despawn_tile_owned(commands, &t);
activity.tiles_dropped += 1;
for uid in t.placements {
release_placement(commands, placements, uid);
release_placement(commands, placements, uid, activity);
}
}
if std::mem::take(&mut state.global_wmo) {
release_placement(commands, placements, GLOBAL_WMO_UID);
release_placement(commands, placements, GLOBAL_WMO_UID, activity);
}
placements.materials.clear();
}
@@ -826,6 +897,7 @@ fn release_world(
mut state: ResMut<TerrainStreamer>,
mut placements: ResMut<Placements>,
mut progress: Option<ResMut<WorldLoadProgress>>,
mut activity: ResMut<crate::perf::StreamActivity>,
) {
if state.tiles.is_empty() && state.map_dir.is_none() {
return;
@@ -834,7 +906,7 @@ fn release_world(
"terrain: leaving the world — releasing {} tiles",
state.tiles.len()
);
drop_streamed_world(&mut commands, &mut state, &mut placements);
drop_streamed_world(&mut commands, &mut state, &mut placements, &mut activity);
state.map_dir = None;
state.wdt = None;
state.wdt_ungated = false;
@@ -872,7 +944,12 @@ fn sync_interior_volumes(placements: Res<Placements>, mut vols: ResMut<WmoReside
/// A tile referencing this placement unloaded: drop one ref, despawning its entities (render submeshes
/// + the avian collider entity) at zero — the collider rides the placement's entity lifecycle.
fn release_placement(commands: &mut Commands, placements: &mut Placements, uid: u32) {
fn release_placement(
commands: &mut Commands,
placements: &mut Placements,
uid: u32,
activity: &mut crate::perf::StreamActivity,
) {
let drop_it = match placements.by_id.get_mut(&uid) {
Some(p) => {
p.refs -= 1;
@@ -882,6 +959,8 @@ fn release_placement(commands: &mut Commands, placements: &mut Placements, uid:
};
if drop_it {
if let Some(p) = placements.by_id.remove(&uid) {
activity.placements_dropped += 1;
activity.placement_entities_dropped += p.entities.len() as u32;
for e in p.entities {
commands.entity(e).try_despawn();
}
@@ -85,6 +85,7 @@ pub(super) fn finish_colliders(
// The walk itself covers the whole set regardless (it is the ~0.005 ms part) — it also yields
// the queue-depth publish below.
const READY_SCAN_CAP: usize = 2048;
let t0 = Instant::now();
let mut ready: Vec<Entity> = Vec::new();
let mut pending = 0usize;
for (entity, mut pc) in state.get_mut(world).iter_mut() {
@@ -110,12 +111,17 @@ pub(super) fn finish_colliders(
progress.colliders_pending = pending;
}
if ready.is_empty() {
// `get_`: the unit tests below drive this system on a minimal App without the perf layer.
if let Some(mut activity) = world.get_resource_mut::<crate::perf::StreamActivity>() {
activity.collider_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
return;
}
// Pass 2 — the main-thread cost. The budget is checked *after* each attach so one oversized
// collider still makes progress rather than stalling the queue forever (the rule
// `stream_terrain` already applies to its tile spawns).
let mut attached = 0u32;
let deadline = Instant::now() + ATTACH_BUDGET;
for entity in ready {
// The entity may have streamed out (despawned) while its collider was building, or had its
@@ -136,10 +142,15 @@ pub(super) fn finish_colliders(
if let Some(layers) = pc.layers {
entity_mut.insert(layers);
}
attached += 1;
if Instant::now() >= deadline {
break;
}
}
if let Some(mut activity) = world.get_resource_mut::<crate::perf::StreamActivity>() {
activity.colliders_attached += attached;
activity.collider_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
}
/// The world-space `(vertices, triangles)` for a model's static collider — its raw-WoW collision hull
@@ -150,9 +150,12 @@ pub(super) fn spawn_emitters_for(
commands,
em,
transform,
owner,
None, // placed doodads/props are never attached models
None, // anchor at the placement: an animated bone never drags the risen cloud
particles::EmitterFrames {
owner,
attach: None, // placed doodads/props are never attached models
anchor: None, // anchor at the placement: an animated bone never drags the cloud
..default()
},
// A placed doodad's arm is NOT one-time: it re-rolls its variation every play-window
// (decision 0768), so the emitters read the slot AND the clip time off the host's live
// player each frame — the same lane units and GameObjects use, and what the reference's
@@ -203,6 +206,8 @@ pub(super) fn spawn_ribbons_for(
use_pivot,
transform.scale.max_element(),
None,
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
);
}
}
+34 -5
View File
@@ -34,6 +34,12 @@ use super::{
ShadeResolve, TerrainStreamer, WmoDoodadInst, SPAWN_BUDGET,
};
/// Placements (models or WMO props) spawned per frame while live — the count half of the landing
/// budget; see the cap's comment in [`spawn_loaded_placements`]. ~150 × the measured ~50 µs
/// downstream cost per landed placement ≈ half a 60 Hz frame of render-side work, and a fresh
/// Undercity row (~3900 placements) fully furnishes in under half a second, two tiles out.
const SPAWN_COUNT_CAP: usize = 150;
/// Spawn the submesh entities for any placement whose model asset has finished loading. Each submesh
/// gets the production [`WowModelMaterial`] (cutout + blend twin) and the same `ModelPart`/`DoodadFade`/
/// `MeshTag` components the old spawn used — so the existing visibility/fade/lighting systems take over.
@@ -58,13 +64,31 @@ pub(super) fn spawn_loaded_placements(
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
// Nested to stay inside Bevy's 16-element system-param tuple limit: the prop-probe table +
// the skin-palette table (decision 0720).
tables: (ResMut<PropProbes>, ResMut<crate::rig_palette::RigPalettes>),
// the skin-palette table (decision 0720) + the stream-trace counters + the live/settling
// state the landing cap reads.
tables: (
ResMut<PropProbes>,
ResMut<crate::rig_palette::RigPalettes>,
ResMut<crate::perf::StreamActivity>,
Option<Res<crate::player::Player>>,
),
) {
let (mut probes, mut palettes) = tables;
let (mut probes, mut palettes, mut activity, player) = tables;
let Some(shared_light) = shared_light else {
return;
};
let t0 = Instant::now();
// The landing COUNT cap (B181), on top of the time budget below. The clock gates main-thread
// cost — but ~1000 one-submesh doodads pass a 4 ms budget in 2.5 ms and hand the render world
// their whole extract/specialize/upload wave in one frame: the measured 60–80 ms landing
// frame when a fresh Undercity row streams in. Counting bounds the downstream wave the clock
// cannot see. Idle until the body is live-and-settled — the entry/teleport cover exists to
// absorb exactly this burst, and capping under it would only lengthen the reveal.
let count_cap = match player.as_deref() {
Some(p) if p.active && !p.settling && !p.world_stale => SPAWN_COUNT_CAP,
_ => usize::MAX,
};
let mut spawned_n = 0usize;
// The animated-doodad clock origin (decision 0130): per-instance phase = spawn time, as in the
// reference (the arm-time cursor offset), and the draw gate seeks against it on resume.
let now = time.elapsed_secs();
@@ -380,9 +404,11 @@ pub(super) fn spawn_loaded_placements(
};
p.spawned = true;
p.entities = entities;
activity.placements_spawned += 1;
spawned_n += 1;
// Spent the frame's spawn budget on this model (a WMO with two collider meshes is the
// costly case) — defer the rest, including this placement's WMO props, to next frame.
if Instant::now() >= deadline {
if Instant::now() >= deadline || spawned_n >= count_cap {
break 'placements;
}
}
@@ -531,11 +557,14 @@ pub(super) fn spawn_loaded_placements(
);
p.entities.extend(ents);
d.spawned = true;
if Instant::now() >= deadline {
activity.placements_spawned += 1;
spawned_n += 1;
if Instant::now() >= deadline || spawned_n >= count_cap {
break 'placements;
}
}
}
activity.spawn_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
/// Resolve a WMO instance's visible doodad props: the global set (0, always shown) plus the
+25
View File
@@ -147,6 +147,31 @@ pub(crate) fn send_spell_cast(
cast_errors.0.push((spell_id, 0x39));
return;
}
// The shapeshift-form leg of the SAME requirement validator (`0x6094f0` at `0x609e49` →
// the form gate `0x612480`; wow-re `shapeshift-plaincast-toggle.md` §Q3, which corrected
// `mounted-action-gate.md`'s `0x609ca2` gloss — that address is the POSTURE gate, reason
// 0x3e NOT_STANDING; vmangos corroborates the reason split,
// `SpellEntry::GetErrorAtShapeshiftedCast`): a form-blocked press refuses locally with the
// gate's own red line — 0x3d "Can't do that while shapeshifted" / 0x56 needs-a-form — and
// never sends, exactly like the mounted leg above. This is the whole Ghost Wolf experience:
// ordinary spells carry NOT_SHAPESHIFT (verified in the 5875 data), so a shifted shaman's
// every press lands here.
if let Some(d) = def {
let form = ctx
.rel
.self_store
.map(|s| s.0.unit_shapeshift_form())
.unwrap_or(0);
let form_is_stance = spells
.and_then(|s| s.forms.get(&u32::from(form)))
.is_some_and(|f| f.is_stance());
if let Some(refusal) = d.form_refusal(form, form_is_stance) {
let reason = refusal.reason();
debug!("ui_action: cast {spell_id} refused locally — the form gate ({reason:#x})");
cast_errors.0.push((spell_id, reason));
return;
}
}
// The pre-send totem/reagent possession check (`CheckReagentsAndTotems 0x6e4000`, TryCast's
// `0x6e4ded` — decision 0552): a missing tool (Mining Pick) or a short reagent refuses HERE
// with the client's own 0x78/0x5c red line and NEVER sends. The gate must be local: vmangos
+16
View File
@@ -178,6 +178,22 @@ pub(super) fn drain_action_uses(
errors.2.clear();
continue;
}
// The active-action toggle (`0x4e55f0` → the `0x4e60c1` cancel; wow-re
// `shapeshift-plaincast-toggle.md`): a live ActiveIconID spell re-pressed on
// its button cancels its own aura — Ghost Wolf, the druid forms, Stealth. The
// form-match toggle is deliberately NOT here (the ref's `UseAction` has no such
// leg — the `CastSpell` dispatcher alone carries it; keep the asymmetry).
if let Some(d) = spells.as_ref().and_then(|s| s.catalog.get(b.action)) {
if let Some(store) = targeting.self_store.iter().next() {
if super::toggle::active_action_toggle(b.action, d, store) {
debug!("ui_action: cast {} re-pressed — aura cancels", b.action);
let _ = commands
.0
.send(crate::net::ClientCommand::CancelAura { spell_id: b.action });
continue;
}
}
}
debug!("ui_action: cast {} (target {:?})", b.action, selection.guid);
send_spell_cast(
b.action,
+1
View File
@@ -45,6 +45,7 @@ mod feed;
mod feed_tests;
mod state;
pub(crate) mod targeting;
pub(crate) mod toggle;
mod weapon_icon;
pub(crate) use cast_send::send_spell_cast;
+126
View File
@@ -0,0 +1,126 @@
//! The plain-path **press-again-to-cancel** toggles — wow-re `shapeshift-plaincast-toggle.md`
//! (§5 cross-checked 2026-07-31). The reference client cancels an active toggle spell in its
//! DISPATCHERS, above `TryCast 0x6e4b60` (which itself has no cancel branch), and the two plain
//! paths are deliberately asymmetric:
//!
//! - **Active-action toggle** ([`active_action_toggle`], predicate `0x4e55f0` and its byte-twin
//! `0x4b36f0`) — on BOTH `UseAction 0x4e5ee0` (cancel `0x4e60c1`) and the `CastSpell`/
//! `CastSpellByName` dispatcher `0x4b3300` (cancel `0x4b3466`): the spell's raw
//! **ActiveIconID ≠ 0** and its own id sits in a live `UNIT_FIELD_AURA` slot with the
//! AURAFLAGS **cancelable bit** (nibble bit 0). No `SpellShapeshiftForm.dbc` guard — the
//! data is the guard (a warrior stance has ActiveIconID 0, so the predicate never fires).
//! Ghost Wolf, the druid forms, Stealth and Shadowform all carry a nonzero column.
//! - **Form-match toggle** ([`form_recast_disposition`], `0x4b348b`–`0x4b35e5`) — ONLY inside
//! `0x4b3300`, never `UseAction`: the spell's MOD_SHAPESHIFT form equals the caster's form
//! byte → cancel (`0x4b35da`), guarded by `SpellShapeshiftForm.flags1 & 0x2` (`0x4b35cf`, the
//! separately-compiled twin of the stance bar's `0x4b4963`): guard set = **silent no-op**
//! (bare epilogue, not a fall-through to cast).
//!
//! Both cancels are `Spell_C::CancelAura 0x6e7040` → `CMSG_CANCEL_AURA` — wire-identical to a
//! buff right-click. Its own internal guard (`AttributesEx & 0x2000` set and `& 0x4` clear)
//! never fires for the shipped toggle spells, and stays unmodeled.
use benilla_formats::{ShapeshiftForm, SpellDisplay};
use crate::net::ObjectStore;
/// The generic active-action toggle predicate (`0x4e55f0`/`0x4b36f0`): a nonzero raw
/// ActiveIconID, and the spell's OWN id live in the player's aura slots with the cancelable
/// flag. True = the press cancels the aura instead of casting.
pub(crate) fn active_action_toggle(spell_id: u32, d: &SpellDisplay, store: &ObjectStore) -> bool {
d.active_icon_id != 0
&& store
.0
.unit_auras()
.any(|a| a.spell_id == spell_id && a.is_cancelable())
}
/// The shapeshift-specific recast fork (`0x4b348b`–`0x4b35e5`, and the stance bar's own
/// MOD_SHAPESHIFT case — the same twin guard): `None` = not the active form, fall through to
/// the cast; `Some(true)` = cancel the form aura; `Some(false)` = the non-cancelable guard
/// (`SpellShapeshiftForm.flags1 & 0x2`) — a silent no-op.
pub(crate) fn form_recast_disposition(
d: &SpellDisplay,
form_byte: u8,
form_row: Option<&ShapeshiftForm>,
) -> Option<bool> {
if form_byte == 0 || d.shapeshift_form != Some(u32::from(form_byte)) {
return None;
}
Some(form_row.is_some_and(|r| r.cancelable()))
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::ObjectFields;
/// Aura field indices mirrored from the protocol crate's own consts (private there):
/// UNIT_FIELD_AURA 47, UNIT_FIELD_AURAFLAGS 95 (nibble-packed, 8 slots per u32).
fn store_with_aura(spell_id: u32, flags_nibble: u32) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(47u16, spell_id),
(95, flags_nibble),
]))
}
/// Mechanism 1: fires only on ActiveIconID ≠ 0 AND own live cancelable aura.
#[test]
fn active_action_toggle_needs_the_icon_and_the_live_cancelable_aura() {
let ghost_wolf = SpellDisplay {
active_icon_id: 1,
..Default::default()
};
let plain = SpellDisplay::default();
// Occupied (eff-index bit) + cancelable bit 0 → 0xb covers both.
let shifted = store_with_aura(2645, 0xb);
assert!(active_action_toggle(2645, &ghost_wolf, &shifted));
// No active icon: never a toggle, whatever the aura says.
assert!(!active_action_toggle(2645, &plain, &shifted));
// Aura present but NOT cancelable (eff-index bits only): no toggle.
let uncancelable = store_with_aura(2645, 0x2);
assert!(!active_action_toggle(2645, &ghost_wolf, &uncancelable));
// A different spell's aura: no toggle.
assert!(!active_action_toggle(768, &ghost_wolf, &shifted));
// No aura at all: no toggle.
let bare = ObjectStore(ObjectFields::from_pairs(&[]));
assert!(!active_action_toggle(2645, &ghost_wolf, &bare));
}
/// Mechanism 2: active-form match forks cancel / silent no-op on the flags1 & 0x2 guard;
/// any other form falls through.
#[test]
fn form_recast_forks_on_the_active_form_and_the_cancelable_guard() {
let ghost_wolf = SpellDisplay {
shapeshift_form: Some(16),
..Default::default()
};
let wolf_row = ShapeshiftForm {
flags: 0x40,
..Default::default()
};
let stance_row = ShapeshiftForm {
flags: 0x7,
..Default::default()
};
assert_eq!(
form_recast_disposition(&ghost_wolf, 16, Some(&wolf_row)),
Some(true)
);
assert_eq!(
form_recast_disposition(&ghost_wolf, 16, Some(&stance_row)),
Some(false),
"the non-cancelable guard is a silent no-op, never a cast"
);
assert_eq!(
form_recast_disposition(&ghost_wolf, 0, Some(&wolf_row)),
None
);
assert_eq!(
form_recast_disposition(&ghost_wolf, 1, Some(&wolf_row)),
None
);
let plain = SpellDisplay::default();
assert_eq!(form_recast_disposition(&plain, 16, Some(&wolf_row)), None);
}
}
+45
View File
@@ -51,6 +51,9 @@ enum Pending {
bonus: u32,
tries: u16,
},
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`): the toast + conditional chat line pair
/// (the drain fires them — the toast needs the script, which only [`feed_chat`] holds).
Discovery { area: String, xp: u32 },
/// A ready event (client-composed lines; name-carrying notices).
Event(ChatEvent),
}
@@ -139,6 +142,18 @@ impl ChatLog {
}
}
/// Queue an area discovery's announcement (`SMSG_EXPLORATION_EXPERIENCE`; decision 0828,
/// surfaces corrected by the 0829 RE): the ERR_ZONE_EXPLORED toast fires on **every** packet
/// (UIErrorsFrame, via `UI_INFO_MESSAGE` — never chat), and the ERR_ZONE_EXPLORED_XP chat
/// system line rides **additionally** iff `xp > 0`. The caller resolved `area_name` from
/// `AreaTable.dbc` by the packet's area id.
pub(crate) fn push_exploration(&mut self, area_name: &str, xp: u32) {
self.pending.push(Pending::Discovery {
area: area_name.to_string(),
xp,
});
}
/// Queue our ding's chat lines (`SMSG_LEVELUP_INFO` → the reference PLAYER_LEVEL_UP handler;
/// decision 0304). `talent_points` is the handler's arg4, client-derived (the packet
/// carries none).
@@ -207,6 +222,20 @@ pub(super) fn xp_gain_line(victim: Option<&str>, total: u32, bonus: u32) -> Stri
}
}
/// The discovery toast — GlobalStrings ERR_ZONE_EXPLORED ("Discovered: %s"), fired on every
/// exploration packet to the UIErrorsFrame (byte-verified: error-table route 1 →
/// `AddErrorMessage 0x4945b0` → UI_INFO_MESSAGE; decisions 0828/0829).
pub(super) fn exploration_toast(area_name: &str) -> String {
format!("Discovered: {area_name}")
}
/// The discovery chat line — GlobalStrings ERR_ZONE_EXPLORED_XP ("Discovered %s: %d experience
/// gained"), fired **in addition to** the toast iff the packet carried XP (byte-verified: the
/// signed `jle` skip at `0x5e422f`; route 0 → CHAT_MSG_SYSTEM; decisions 0828/0829).
pub(super) fn exploration_line(area_name: &str, xp: u32) -> String {
format!("Discovered {area_name}: {xp} experience gained")
}
/// Build the event a channel notice becomes: JOINED/LEFT → the member-line kinds; the rest →
/// CHANNEL_NOTICE (composed by [`super::frames::compose_notice`], the notice byte riding the
/// event's `notice` field).
@@ -495,6 +524,22 @@ pub(super) fn feed_chat(
),
);
}
Pending::Discovery { area, xp } => {
// The toast fires every time; the chat line only rides XP (decisions 0828/0829).
script.fire_event(
"UI_INFO_MESSAGE",
vec![benilla_ui::script::ScriptValue::Str(exploration_toast(
&area,
))],
);
if xp > 0 {
route(
&mut script,
&mut windows,
&ChatEvent::text_only(ChatEventKind::System, exploration_line(&area, xp)),
);
}
}
}
}
log.pending = still;
+15
View File
@@ -166,6 +166,21 @@ fn xp_gain_lines_pick_the_reference_form() {
assert_eq!(default_color(K::CombatXpGain), [111, 111, 255]);
}
#[test]
fn exploration_lines_pick_the_reference_form() {
// ERR_ZONE_EXPLORED (GlobalStrings :1925) — the toast, fired on EVERY exploration packet
// (UIErrorsFrame); ERR_ZONE_EXPLORED_XP (:1926) — the chat system line that rides
// additionally iff xp > 0 (byte-verified branch `0x5e422f`; decisions 0828/0829).
assert_eq!(
super::feed::exploration_toast("Westfall"),
"Discovered: Westfall"
);
assert_eq!(
super::feed::exploration_line("Westfall", 85),
"Discovered Westfall: 85 experience gained"
);
}
#[test]
fn monster_lines_use_the_bare_inline_name() {
assert_eq!(
+18 -14
View File
@@ -6,9 +6,11 @@
//! - **Admission** (the list build `0x4b25b0`): a KNOWN spell joins the bar when
//! `AttributesEx2 & 0x2 == 0` AND (it carries a `SPELL_AURA_MOD_SHAPESHIFT` apply-aura effect
//! OR `AttributesEx2 & 0x10` force-admits it). 5875 data: warrior stances (`ex2 0x1`), druid
//! forms (`0x0`), and Stealth (`0x200000`) all pass the exclusion bit; no shipped spell needs
//! the force-admit bit, so its aura-scan `isActive` leg reads `false` here (a named gap, not a
//! divergence — there is no spell to diverge on).
//! forms (`0x0`), and Stealth (`0x200000`) all pass the exclusion bit; **Ghost Wolf 2645
//! (`ex2 0x2`) is the shipped carrier of the exclusion** — a shaman faithfully gets NO stance
//! bar, and cancels the form at the buff frame instead (verified in the data, 2026-07-31). No
//! shipped spell needs the force-admit bit, so its aura-scan `isActive` leg reads `false` here
//! (a named gap, not a divergence — there is no spell to diverge on).
//! - **Order** (comparator `0x4b2bb0`): ascending `Spell.dbc` `StanceBarOrder`, negative last,
//! spell id tiebreak. (Battle 0 / Def 1 / Berserker 2; Bear 0 … Moonkin 4; Stealth −1 → last.)
//! - **texture**: the form SPELL's icon — `ActiveIconID` while active when nonzero (druid forms'
@@ -46,7 +48,8 @@ use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// `AttributesEx2` bit `0x2` — EXCLUDES a spell from the stance bar (the `0x4b25b0` gate's
/// first leg). No shipped form spell carries it; the gate is real in the binary regardless.
/// first leg). Ghost Wolf 2645 is the shipped carrier (module docs) — the reason a shaman has
/// no stance bar.
const ATTR_EX2_STANCE_BAR_EXCLUDE: u32 = 0x2;
/// `AttributesEx2` bit `0x10` — FORCE-ADMITS a spell without a MOD_SHAPESHIFT effect (the
/// gate's or-leg). No shipped 5875 spell uses it either.
@@ -208,19 +211,20 @@ fn drain_shapeshift_casts(
.map(|s| s.0.unit_shapeshift_form())
.unwrap_or(0);
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
let active =
form_byte != 0 && d.and_then(|d| d.shapeshift_form) == Some(u32::from(form_byte));
if active {
// The 0x4b4963 guard: a non-cancelable form (warrior stances) is a silent no-op.
let cancelable = spells
.as_ref()
.and_then(|s| s.forms.get(&u32::from(form_byte)))
.is_some_and(|r| r.cancelable());
if cancelable {
// The active-form fork — shared with the plain `CastSpell` dispatcher's twin
// (`crate::ui_action::toggle`): cancel unless the `0x4b4963` flags1-&-0x2 guard makes
// it a silent no-op (warrior stances).
let row = spells
.as_ref()
.and_then(|s| s.forms.get(&u32::from(form_byte)));
match d.and_then(|d| crate::ui_action::toggle::form_recast_disposition(d, form_byte, row)) {
Some(true) => {
debug!("ui_shapeshift: cancel form aura {spell_id}");
let _ = commands.0.send(ClientCommand::CancelAura { spell_id });
continue;
}
continue;
Some(false) => continue,
None => {}
}
debug!("ui_shapeshift: cast form {spell_id}");
send_spell_cast(
+33
View File
@@ -310,6 +310,39 @@ fn drain_spell_casts(
return;
};
for spell_id in script.take_spell_casts() {
// The `CastSpell` dispatcher's two press-again-to-cancel forks (`0x4b3300`, wow-re
// `shapeshift-plaincast-toggle.md`), in the ref's own order: the active-action toggle
// (`0x4b36f0` → cancel `0x4b3466`), then the shapeshift form-match fork
// (`0x4b348b`–`0x4b35e5`) with its non-cancelable silent no-op (`0x4b35cf`). This is the
// leg `UseAction` does NOT have — a druid's `/cast Cat Form` powershift-out and a
// shaman's Ghost Wolf re-cast both land here.
if let (Some(sp), Some(store)) = (spells.as_ref(), targeting.self_store.iter().next()) {
if let Some(d) = sp.catalog.get(spell_id) {
if crate::ui_action::toggle::active_action_toggle(spell_id, d, store) {
debug!("ui_spellbook: cast {spell_id} re-pressed — aura cancels");
let _ = commands
.0
.send(crate::net::ClientCommand::CancelAura { spell_id });
continue;
}
let form = store.0.unit_shapeshift_form();
let row = sp.forms.get(&u32::from(form));
match crate::ui_action::toggle::form_recast_disposition(d, form, row) {
Some(true) => {
debug!("ui_spellbook: cast {spell_id} — the active form cancels");
let _ = commands
.0
.send(crate::net::ClientCommand::CancelAura { spell_id });
continue;
}
Some(false) => {
debug!("ui_spellbook: cast {spell_id} — non-cancelable form, silent no-op");
continue;
}
None => {}
}
}
}
debug!(
"ui_spellbook: cast {spell_id} (target {:?})",
targeting.selection.guid