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