mounting keeps your buff visuals, and tooltips say the enchant
- the mount transition re-seats the rider instead of rebuilding them: shield glows, weapon enchants and worn visuals stay lit through mount and dismount - an enchanted item's tooltip carries its enchant line, coloured per slot, with a temporary enchant's countdown - a server refusal with nothing to say now says nothing: the raw "Inventory error" debug line is gone, real refusals still print - the mail icon clears when you close the mailbox, and translucent models classify against the water plane Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -170,6 +170,13 @@ pub struct ModelEmitter {
|
||||
/// decisions 0719/0721). Computed by [`benilla_formats::m2_owner_reach`], which is where the
|
||||
/// reasoning lives.
|
||||
pub owner_reach: f32,
|
||||
/// The OWNER model's authored bound sphere — the water-plane classification input (wow-re
|
||||
/// `water-frame-straddle.md` §6, byte-VERIFIED): the reference dots `world_matrix ×
|
||||
/// (bbox_min+bbox_max)/2` against the plane once per MODEL and every emitter reads that one
|
||||
/// verdict, with the slack `above ⇔ d ≥ −r`, `r = |matrix row 0| × sphere radius`. Centre in
|
||||
/// **Bevy model space** (the same frame the instance transform maps), radius model-local
|
||||
/// yards. `(ZERO, 0)` when the header carries no bounds — the sign test at the anchor.
|
||||
pub water_bound: (Vec3, f32),
|
||||
}
|
||||
|
||||
/// Whether looping `anim` would look like anything other than the **static mesh** — the doodad
|
||||
@@ -232,6 +239,10 @@ pub struct ModelRibbon {
|
||||
/// trails otherwise interleave with its own body, 6 batches deep, and which batches are over
|
||||
/// which streamer *changes frame to frame* as they whip (decision 0721).
|
||||
pub owner_reach: f32,
|
||||
/// The owner model's bound sphere for the water-plane side — same field, same law as
|
||||
/// [`ModelEmitter::water_bound`]: the ribbon leg reads the MODEL's side-A boolean verbatim
|
||||
/// (wow-re `water-frame-straddle.md` §6 — `cmp [ebp-0x24],0` at `0x7081f1`), slack included.
|
||||
pub water_bound: (Vec3, f32),
|
||||
}
|
||||
|
||||
/// One M2 light block of an [`M2Model`]: the raw [`M2Light`] record plus — like
|
||||
@@ -272,6 +283,18 @@ impl AssetLoader for M2ModelLoader {
|
||||
// authors (quad emitters, ribbon trails, model-particle instances) takes the rung it sizes,
|
||||
// because the reference draws them all in the one post-batch bracket.
|
||||
let owner_reach = benilla_formats::m2_owner_reach(&subs);
|
||||
// The water-plane classification sphere, once for the whole model — the header AABB's
|
||||
// midpoint (Bevy model frame) + the header sphere radius. See [`ModelEmitter::water_bound`].
|
||||
let water_bound = bounds.as_ref().map_or((Vec3::ZERO, 0.0), |b| {
|
||||
(
|
||||
wow_to_bevy([
|
||||
(b.bbox_min[0] + b.bbox_max[0]) * 0.5,
|
||||
(b.bbox_min[1] + b.bbox_max[1]) * 0.5,
|
||||
(b.bbox_min[2] + b.bbox_max[2]) * 0.5,
|
||||
]),
|
||||
b.sphere_radius,
|
||||
)
|
||||
});
|
||||
// The collision hull (collide-iff-hull): keep only a non-empty mesh so the spawn site can skip
|
||||
// hull-less props cheaply.
|
||||
let collision = parse_m2_collision_hull(&bytes)
|
||||
@@ -369,6 +392,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
recursion,
|
||||
geometry,
|
||||
owner_reach,
|
||||
water_bound,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -394,6 +418,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
texture,
|
||||
bone_pivot,
|
||||
owner_reach,
|
||||
water_bound,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -90,14 +90,20 @@ impl ItemVisualCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpellItemEnchantment.dbc`'s **visual column** (field 22): an enchant id → the ItemVisuals id
|
||||
/// its glow comes from. Only the 102 rows that carry one are kept; everything else about the
|
||||
/// enchant table (its effects, points, name) belongs to whoever needs it, not here.
|
||||
pub struct EnchantVisualCatalog {
|
||||
/// `SpellItemEnchantment.dbc`'s two consumer columns, from one load of the one table: the
|
||||
/// **visual** (field 22) an enchant glows with, and the **name** (field 13, the enUS slot of the
|
||||
/// 1.12 localized-string block) the item tooltip prints for it. Two lanes read this table — the
|
||||
/// weapon-glow chain (decision 0805) and the tooltip's enchant line (decision 0915) — and one
|
||||
/// adapter serves both: two loaders over one DBC is how a schema drifts.
|
||||
///
|
||||
/// Sparse on both axes: 102 of the 1460 rows carry a visual, and a row without a name simply has
|
||||
/// none. The rest of the table (effects, points, args) belongs to whoever needs it, not here.
|
||||
pub struct EnchantCatalog {
|
||||
visuals: HashMap<u32, i32>,
|
||||
names: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl EnchantVisualCatalog {
|
||||
impl EnchantCatalog {
|
||||
/// The ItemVisuals id an enchant glows with, or `None` for an enchant with no visual. The
|
||||
/// value is signed for the same reason as [`ItemVisualCatalog::effects`] — one shipped row
|
||||
/// carries `-1`.
|
||||
@@ -105,23 +111,31 @@ impl EnchantVisualCatalog {
|
||||
self.visuals.get(&enchant_id).copied()
|
||||
}
|
||||
|
||||
/// Build from an explicit map — tests and synthetic fixtures.
|
||||
pub fn from_visuals(visuals: HashMap<u32, i32>) -> Self {
|
||||
EnchantVisualCatalog { visuals }
|
||||
/// The enchant's display name as the table stores it — `"Agility +15"`, `"Crusader"`,
|
||||
/// `"Stamina +7"`. `None` for an unknown id or a row with an empty name string.
|
||||
pub fn name(&self, enchant_id: u32) -> Option<&str> {
|
||||
self.names.get(&enchant_id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Build from explicit maps — tests and synthetic fixtures.
|
||||
pub fn from_rows(visuals: HashMap<u32, i32>, names: HashMap<u32, String>) -> Self {
|
||||
EnchantCatalog { visuals, names }
|
||||
}
|
||||
|
||||
/// Iterate `(enchant id, ItemVisuals id)` for the rows that carry one (order unspecified) —
|
||||
/// the cross-table join checks read it.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (u32, i32)> + '_ {
|
||||
pub fn iter_visuals(&self) -> impl Iterator<Item = (u32, i32)> + '_ {
|
||||
self.visuals.iter().map(|(k, v)| (*k, *v))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
/// How many enchants carry a glow — the glow lane's startup census.
|
||||
pub fn visual_count(&self) -> usize {
|
||||
self.visuals.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.visuals.is_empty()
|
||||
/// How many enchants carry a printable name — the tooltip lane's.
|
||||
pub fn name_count(&self) -> usize {
|
||||
self.names.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +160,10 @@ pub(crate) fn item_visual_effects_schema() -> Schema {
|
||||
s
|
||||
}
|
||||
|
||||
/// `SpellItemEnchantment.dbc` — 24 fields / 96-byte records in build 5875. Only field 22
|
||||
/// (`ItemVisual`) is read; the rest are typed to keep the field-count check exact. The eight
|
||||
/// `Name_Lang` slots + their mask are the 1.12 localized-string block (only `enUS` is filled).
|
||||
/// `SpellItemEnchantment.dbc` — 24 fields / 96-byte records in build 5875. Fields 13 (`Name0`)
|
||||
/// and 22 (`ItemVisual`) are read; the rest are typed to keep the field-count check exact. The
|
||||
/// eight `Name_Lang` slots + their mask are the 1.12 localized-string block (only `enUS`, the
|
||||
/// first, is filled).
|
||||
pub(crate) fn spell_item_enchantment_schema() -> Schema {
|
||||
let mut s = Schema::new("SpellItemEnchantment");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
@@ -201,8 +216,9 @@ pub fn load_item_visual_catalog(chain: &mut Chain) -> Result<ItemVisualCatalog>
|
||||
Ok(ItemVisualCatalog { visuals })
|
||||
}
|
||||
|
||||
/// Load `SpellItemEnchantment.dbc`'s visual column off the patch chain.
|
||||
pub fn load_enchant_visual_catalog(chain: &mut Chain) -> Result<EnchantVisualCatalog> {
|
||||
/// Load `SpellItemEnchantment.dbc`'s two consumer columns off the patch chain — one parse, both
|
||||
/// lanes (see [`EnchantCatalog`]).
|
||||
pub fn load_enchant_catalog(chain: &mut Chain) -> Result<EnchantCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(SPELL_ITEM_ENCHANTMENT)
|
||||
.with_context(|| format!("reading {SPELL_ITEM_ENCHANTMENT}"))?;
|
||||
@@ -212,14 +228,19 @@ pub fn load_enchant_visual_catalog(chain: &mut Chain) -> Result<EnchantVisualCat
|
||||
"SpellItemEnchantment",
|
||||
)?;
|
||||
let mut visuals = HashMap::new();
|
||||
let mut names = HashMap::new();
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
let visual = u32_at(r, 22).unwrap_or(0) as i32;
|
||||
if visual != 0 {
|
||||
visuals.insert(id, visual);
|
||||
}
|
||||
// `str_at` drops the empty string, so a nameless row simply never lands.
|
||||
if let Some(name) = str_at(&rs, r, 13) {
|
||||
names.insert(id, name);
|
||||
}
|
||||
}
|
||||
Ok(EnchantVisualCatalog { visuals })
|
||||
Ok(EnchantCatalog { visuals, names })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -347,11 +368,11 @@ mod tests {
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let visuals = load_item_visual_catalog(&mut chain).expect("load ItemVisuals");
|
||||
let enchants = load_enchant_visual_catalog(&mut chain).expect("load SpellItemEnchantment");
|
||||
assert_eq!(enchants.len(), 102, "enchants carrying a visual");
|
||||
let enchants = load_enchant_catalog(&mut chain).expect("load SpellItemEnchantment");
|
||||
assert_eq!(enchants.visual_count(), 102, "enchants carrying a visual");
|
||||
|
||||
let resolved = enchants
|
||||
.iter()
|
||||
.iter_visuals()
|
||||
.filter(|(_, v)| visuals.effects(*v).is_some())
|
||||
.count();
|
||||
assert_eq!(resolved, 101, "…all but the one -1 row resolve to a row");
|
||||
@@ -376,4 +397,36 @@ mod tests {
|
||||
assert_eq!(enchants.visual(241), None);
|
||||
assert_eq!(enchants.visual(929), None);
|
||||
}
|
||||
|
||||
/// The **name** column (field 13), on real data — the string the tooltip's enchant line
|
||||
/// prints (decision 0915). Pinned on the case that opened the lane plus one of each other
|
||||
/// shape, and on the two properties the consumer rests on: the name is stored in the table's
|
||||
/// own word order (`"Agility +15"`, NOT a reformat), and it is independent of the visual
|
||||
/// column — a glowing enchant and a plain +stat one both have one.
|
||||
#[test]
|
||||
fn real_enchant_names_read_off_the_table() {
|
||||
let data = vanilla_data_dir();
|
||||
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 enchants = load_enchant_catalog(&mut chain).expect("load SpellItemEnchantment");
|
||||
|
||||
// 2564 = the permanent weapon enchant on the director's Hatchet of Sundered Bone: a name
|
||||
// AND a visual (125 → GreenGlow_Low), the two columns joined on one row.
|
||||
assert_eq!(enchants.name(2564), Some("Agility +15"));
|
||||
assert_eq!(enchants.visual(2564), Some(125));
|
||||
// Named, no glow — the +stat family.
|
||||
assert_eq!(enchants.name(241), Some("Weapon Damage +2"));
|
||||
assert_eq!(enchants.name(929), Some("Stamina +7"));
|
||||
// Glow, and a name that is a proper noun rather than a stat phrase.
|
||||
assert_eq!(enchants.name(1900), Some("Crusader"));
|
||||
// An id past the table names nothing at all.
|
||||
assert_eq!(enchants.name(999_999), None);
|
||||
assert!(
|
||||
enchants.name_count() > enchants.visual_count(),
|
||||
"far more enchants print a name than carry a glow"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ mod item_sounds;
|
||||
pub use item_sounds::{load_item_group_sounds, ItemGesture, ItemGroupSoundsCatalog};
|
||||
mod item_visuals;
|
||||
pub use item_visuals::{
|
||||
load_enchant_visual_catalog, load_item_visual_catalog, EnchantVisualCatalog, ItemVisualCatalog,
|
||||
load_enchant_catalog, load_item_visual_catalog, EnchantCatalog, ItemVisualCatalog,
|
||||
ITEM_VISUAL_SLOTS,
|
||||
};
|
||||
mod ground_effects;
|
||||
|
||||
@@ -546,6 +546,15 @@ pub enum SessionEvent {
|
||||
},
|
||||
/// Put an item instance on the client's fixed 30 s use cooldown (`SMSG_ITEM_COOLDOWN`).
|
||||
ItemCooldown { item_guid: u64, spell_id: u32 },
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` — the seconds left on one item's TEMPORARY enchant, in the
|
||||
/// named enchant slot. The **only** feed for the tooltip's countdown: the item's own
|
||||
/// `ITEM_FIELD_ENCHANTMENT` duration field is never read for it (wow-re
|
||||
/// `ui/scratch/tooltip-content-law.md` §E3; decision 0920). `seconds == 0` = expired.
|
||||
ItemEnchantTime {
|
||||
item_guid: u64,
|
||||
slot: u32,
|
||||
seconds: u32,
|
||||
},
|
||||
/// Start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's parked timers now
|
||||
/// (`SMSG_COOLDOWN_EVENT`).
|
||||
CooldownEvent { spell_id: u32, caster: u64 },
|
||||
@@ -967,8 +976,9 @@ pub enum SessionEvent {
|
||||
/// (a mail's nonzero `item_text_id` triggers the ask-once fetch; decision 0544).
|
||||
MailItemText { text_id: u32, text: String },
|
||||
/// `SMSG_RECEIVED_MAIL` — a mail arrived (instant for text-only, on the delivery timer's
|
||||
/// expiry otherwise); the wire's always-0 `u32` carries nothing, so the event does either.
|
||||
ReceivedMail,
|
||||
/// expiry otherwise). `seconds` is the delay until it is "waiting", in the pending-mail
|
||||
/// countdown's units (vmangos only ever sends `0.0` = now; decision 0913).
|
||||
ReceivedMail { seconds: f32 },
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode as our empty-body request): `0.0` = unread
|
||||
/// mail waiting, `-86400.0` = none. Drives `HasNewMail()`/`UPDATE_PENDING_MAIL` (decision 0544
|
||||
/// P3).
|
||||
|
||||
@@ -173,6 +173,15 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
item_guid,
|
||||
spell_id,
|
||||
}],
|
||||
ServerPacket::ItemEnchantTime {
|
||||
item_guid,
|
||||
slot,
|
||||
seconds,
|
||||
} => vec![SessionEvent::ItemEnchantTime {
|
||||
item_guid,
|
||||
slot,
|
||||
seconds,
|
||||
}],
|
||||
ServerPacket::CooldownEvent { spell_id, caster } => {
|
||||
vec![SessionEvent::CooldownEvent { spell_id, caster }]
|
||||
}
|
||||
@@ -722,7 +731,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
ServerPacket::ItemTextQueryResponse { text_id, text } => {
|
||||
vec![SessionEvent::MailItemText { text_id, text }]
|
||||
}
|
||||
ServerPacket::ReceivedMail => vec![SessionEvent::ReceivedMail],
|
||||
ServerPacket::ReceivedMail { seconds } => vec![SessionEvent::ReceivedMail { seconds }],
|
||||
ServerPacket::NextMailTime { seconds } => {
|
||||
vec![SessionEvent::NextMailTime { seconds }]
|
||||
}
|
||||
|
||||
@@ -543,31 +543,73 @@ const TARGET_FLAG_GAMEOBJECT: u16 = 0x0800;
|
||||
/// `cursor-system.md` §8.4), so the mask we write is the mask it writes.
|
||||
const TARGET_FLAG_LOCKED: u16 = 0x4000;
|
||||
|
||||
/// `TARGET_FLAG_UNIT` — a bound unit target: the same bit, and the same packed guid, a
|
||||
/// `CMSG_CAST_SPELL` writes. In the real client ONE block builder serves both opcodes, so one bit
|
||||
/// table serves both here.
|
||||
const TARGET_FLAG_UNIT: u16 = 0x0002;
|
||||
/// `TARGET_FLAG_DEST_LOCATION` — a ground point, the same bit and the same three `f32` WoW coords
|
||||
/// a ground-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_at_dest`]).
|
||||
const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040;
|
||||
|
||||
/// The `SpellCastTargets` block a `CMSG_USE_ITEM` carries — built by the *same* code as a
|
||||
/// `CMSG_CAST_SPELL`'s. `SendCast 0x6e54f0` picks the opcode from its item-vs-caster discriminator
|
||||
/// (`0x6e57d8 push 0xab` when the pending-cast block's guid is the item's, `push 0x12e` when it is
|
||||
/// the caster's) and then writes the one targets block `ArmCast 0x6e5250` bound — item or not
|
||||
/// (wow-re `action-item-slot.md` §8: "body `{u8 bagIndex, u8 slot, u8 spell_index}` + the
|
||||
/// cast-targets block"; `cursor-system.md` §8.4a for the split).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum UseItemTarget {
|
||||
/// Mask 0 (`TARGET_FLAG_SELF`) — the implicit self-cast an ordinary consumable sends, whose
|
||||
/// target the server resolves itself. What 81% of the 1.12 on-use items bind to.
|
||||
#[default]
|
||||
SelfImplicit,
|
||||
/// `TARGET_FLAG_UNIT` + the packed guid — an item whose spell binds a unit exactly as a
|
||||
/// spell's does: a bandage, a soulstone, an offensive trinket.
|
||||
Unit(u64),
|
||||
/// `TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED` + the packed guid — the **key-in-a-lock**
|
||||
/// case (decision 0769): opening a locked door or chest with a key is not a spell cast, it is
|
||||
/// *using the key at the object*. It matters that this is USE_ITEM and not a bare cast:
|
||||
/// `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot **only** when `m_CastItem` is set
|
||||
/// (`Spell.cpp:7892`), which only this packet supplies.
|
||||
Object(u64),
|
||||
/// `TARGET_FLAG_DEST_LOCATION` + three `f32` WoW coords — the targeting-cursor commit for a
|
||||
/// **thrown** item: dynamite, grenades, bombs, the Goblin Mortar (46 of the 1.12 on-use item
|
||||
/// spells, decision 0914). Same block a ground-targeted spell writes; only the opcode differs.
|
||||
Dest([f32; 3]),
|
||||
}
|
||||
|
||||
/// Body of `CMSG_USE_ITEM` (VERIFIED vmangos `UseItem::ReadFromWorldPacket` + opcode 171
|
||||
/// `Opcodes_1_12_1.h`): `bagIndex` (a bag's player-array slot 19–22, or [`BAG_PLAYER_INVENTORY`]
|
||||
/// with an absolute `slot`), `slot` (0-based within the bag), `spellSlot` (which of the template's
|
||||
/// 5 spell effects — 0, the "use" effect, for a plain use), then a `SpellCastTargets` block.
|
||||
///
|
||||
/// `go_target` is the **key-in-a-lock** case (decision 0769): opening a locked door or chest with a
|
||||
/// key is not a spell cast, it is *using the key at the object*, and the packet is this one with
|
||||
/// `TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED` and the object's packed guid (wow-re
|
||||
/// `cursor-system.md` §8.4, byte-read: the sender `0x6e54f0` discriminates on cast-item-vs-caster
|
||||
/// and the item arm falls to `0x6e57d8 push 0xab`). It matters that this is USE_ITEM and not a bare
|
||||
/// cast: `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot **only** when `m_CastItem` is set
|
||||
/// (`Spell.cpp:7892`), which only this packet supplies. `None` writes mask 0 — the self-shaped
|
||||
/// block a consumable sends, whose implicit target the server resolves itself.
|
||||
pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, go_target: Option<u64>) -> Vec<u8> {
|
||||
/// 5 spell effects — 0, the "use" effect, for a plain use), then a `SpellCastTargets` block
|
||||
/// ([`UseItemTarget`]).
|
||||
pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(5);
|
||||
body.push(bag_index);
|
||||
body.push(slot);
|
||||
body.push(spell_slot);
|
||||
match go_target {
|
||||
None => body.extend_from_slice(&0u16.to_le_bytes()),
|
||||
Some(guid) => {
|
||||
body.extend_from_slice(&(TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED).to_le_bytes());
|
||||
crate::wire::write_packed_guid(guid, &mut body).expect("write to Vec cannot fail");
|
||||
let guid = match target {
|
||||
UseItemTarget::SelfImplicit => {
|
||||
body.extend_from_slice(&0u16.to_le_bytes());
|
||||
return body;
|
||||
}
|
||||
}
|
||||
UseItemTarget::Unit(guid) => {
|
||||
body.extend_from_slice(&TARGET_FLAG_UNIT.to_le_bytes());
|
||||
guid
|
||||
}
|
||||
UseItemTarget::Object(guid) => {
|
||||
body.extend_from_slice(&(TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED).to_le_bytes());
|
||||
guid
|
||||
}
|
||||
UseItemTarget::Dest(dest) => {
|
||||
body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes());
|
||||
for c in dest {
|
||||
body.extend_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
};
|
||||
crate::wire::write_packed_guid(guid, &mut body).expect("write to Vec cannot fail");
|
||||
body
|
||||
}
|
||||
|
||||
@@ -668,6 +710,22 @@ pub(super) fn read_inventory_change_failure(r: &mut &[u8]) -> io::Result<(u8, Op
|
||||
Ok((reason, required_level, item_guid))
|
||||
}
|
||||
|
||||
/// Read `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos
|
||||
/// `WorldPackets::Item::ItemEnchantTimeUpdate::AppendBodyTo`, `Server/Packets/Item.cpp:161-169`):
|
||||
/// item guid, enchant **slot**, remaining **seconds**, then the owning player's guid (present from
|
||||
/// build 1.10.2 up, so always in 1.12). Returns `(item_guid, slot, seconds)`; the trailing player
|
||||
/// guid is dropped — the packet only ever concerns our own items and the item guid already names
|
||||
/// which one.
|
||||
///
|
||||
/// `seconds == 0` means expired, and the reference stores that as **no timer** (`0x5d9cc0` writes
|
||||
/// a `0` deadline whenever `seconds <= 0`), not as "0 seconds left".
|
||||
pub(super) fn read_item_enchant_time(r: &mut &[u8]) -> io::Result<(u64, u32, u32)> {
|
||||
let item_guid = read_u64_le(r)?;
|
||||
let slot = read_u32_le(r)?;
|
||||
let seconds = read_u32_le(r)?;
|
||||
Ok((item_guid, slot, seconds))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -748,6 +806,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE`'s body, byte-exact against vmangos's writer: guid, slot,
|
||||
/// seconds, then the player guid we drop. Pinned because a wrong width here would silently
|
||||
/// park a garbage deadline (decision 0920).
|
||||
#[test]
|
||||
fn item_enchant_time_reads_guid_slot_seconds() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&0x4000_0000_0000_00f7u64.to_le_bytes()); // itemGuid
|
||||
buf.extend_from_slice(&1u32.to_le_bytes()); // slot 1 = TEMP
|
||||
buf.extend_from_slice(&600u32.to_le_bytes()); // seconds
|
||||
buf.extend_from_slice(&0x0000_0000_0000_0001u64.to_le_bytes()); // playerGuid (dropped)
|
||||
let mut r = &buf[..];
|
||||
assert_eq!(
|
||||
read_item_enchant_time(&mut r).unwrap(),
|
||||
(0x4000_0000_0000_00f7, 1, 600)
|
||||
);
|
||||
// The trailing player guid stays unread — the reader consumes exactly its three fields.
|
||||
assert_eq!(r.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_failure_nonlevel_branch_has_no_u32() {
|
||||
// Any failed reason != 1 skips requiredLevel: item1Guid u64, item2Guid u64, bagSlot u8.
|
||||
|
||||
@@ -300,12 +300,17 @@ pub(super) fn read_item_text_query_response(r: &mut &[u8]) -> io::Result<(u32, S
|
||||
Ok((read_u32_le(r)?, read_cstring(r)?))
|
||||
}
|
||||
|
||||
/// Read `SMSG_RECEIVED_MAIL` (VERIFIED vmangos `Server/Packets/Mail.cpp`,
|
||||
/// `ReceivedMail::AppendBodyTo`): one `u32`, always `0` on vmangos — kept for fidelity though no
|
||||
/// consumer reads it back. Sent when a mail *arrives* (instant for text-only, on the delivery
|
||||
/// timer's expiry otherwise).
|
||||
pub(super) fn read_received_mail(r: &mut &[u8]) -> io::Result<u32> {
|
||||
read_u32_le(r)
|
||||
/// Read `SMSG_RECEIVED_MAIL`: one **`f32` delay** — the seconds until the mail is "waiting", the
|
||||
/// same countdown units as [`read_query_next_mail_time`]. Sent when a mail *arrives* (instant for
|
||||
/// text-only, on the delivery timer's expiry otherwise).
|
||||
///
|
||||
/// The real client reads these four bytes as a float and feeds them to the pending-mail countdown's
|
||||
/// set-value ladder (VERIFIED wow-re, handler `0x4ad620`; decision 0913). vmangos writes them as
|
||||
/// `uint32(0)` (`MailHandler.cpp::SendNewMail`) and `0x00000000` *is* `0.0f`, so the two agree
|
||||
/// exactly on the only value vmangos ever sends — reading it the way the client does costs nothing
|
||||
/// and keeps a server that sends a real delay working.
|
||||
pub(super) fn read_received_mail(r: &mut &[u8]) -> io::Result<f32> {
|
||||
read_f32_le(r)
|
||||
}
|
||||
|
||||
/// Read `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode both directions — our request is an EMPTY
|
||||
|
||||
@@ -107,7 +107,7 @@ pub use group::{
|
||||
pub use items::{
|
||||
auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
|
||||
split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
|
||||
ItemUseSpell, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED,
|
||||
ItemUseSpell, UseItemTarget, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED,
|
||||
ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
|
||||
};
|
||||
pub use loot::{
|
||||
|
||||
@@ -153,6 +153,14 @@ pub const SMSG_COOLDOWN_EVENT: u16 = 0x0135; // 309
|
||||
pub const SMSG_CLEAR_COOLDOWN: u16 = 0x01DE; // 478
|
||||
pub const SMSG_COOLDOWN_CHEAT: u16 = 0x01E1; // 481
|
||||
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos `Opcodes_1_12_1.h`: 491) — the **only** source
|
||||
/// of a temporary enchant's remaining time. The item's `ITEM_FIELD_ENCHANTMENT` duration field is
|
||||
/// never read for it: the reference's tooltip reads a client-side per-slot deadline array
|
||||
/// (`[obj + slot*4 + 0x324]`) whose only writers are this packet's handler `0x5e4f82` and the item
|
||||
/// refresh `0x5ebe34` (wow-re `ui/scratch/tooltip-content-law.md` §E3, byte-verified). Body in
|
||||
/// [`super::items::read_item_enchant_time`]; decision 0920.
|
||||
pub const SMSG_ITEM_ENCHANT_TIME_UPDATE: u16 = 0x01EB; // 491
|
||||
|
||||
/// Pushback (VERIFIED vmangos `Opcodes_1_12_1.h`: 482): sent to the caster when a cast takes damage
|
||||
/// and the server delays it (`Spell::Delayed`, `Spell.cpp:7472` — a raw `u64` caster guid + `u32`
|
||||
/// delaytime ms). The cast bar slides its window out by it (`SPELLCAST_DELAYED`); a normal hit never
|
||||
|
||||
@@ -443,6 +443,14 @@ pub enum ServerPacket {
|
||||
item_guid: u64,
|
||||
spell_id: u32,
|
||||
},
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` — how long one item's TEMPORARY enchant has left (layout in
|
||||
/// [`super::items::read_item_enchant_time`]). The tooltip's countdown has no other source
|
||||
/// (decision 0920).
|
||||
ItemEnchantTime {
|
||||
item_guid: u64,
|
||||
slot: u32,
|
||||
seconds: u32,
|
||||
},
|
||||
/// `SMSG_COOLDOWN_EVENT` — start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's
|
||||
/// parked timers now (layout in [`super::spellbook::read_cooldown_event`]).
|
||||
CooldownEvent {
|
||||
@@ -995,8 +1003,12 @@ pub enum ServerPacket {
|
||||
text: String,
|
||||
},
|
||||
/// `SMSG_RECEIVED_MAIL` — a mail arrived (instant for text-only, on the delivery timer's
|
||||
/// expiry otherwise); the wire's lone `u32` is always 0 on vmangos, so nothing rides this.
|
||||
ReceivedMail,
|
||||
/// expiry otherwise). `seconds` is the delay until it is "waiting", in the countdown's units:
|
||||
/// vmangos only ever sends `0.0` ("now"), but the real client reads a float here and runs it
|
||||
/// through the countdown's set-value ladder (decision 0913).
|
||||
ReceivedMail {
|
||||
seconds: f32,
|
||||
},
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode as our empty-body request): `0.0` = unread
|
||||
/// mail waiting, `-86400.0` = none.
|
||||
NextMailTime {
|
||||
@@ -1090,6 +1102,7 @@ impl ServerPacket {
|
||||
ServerPacket::CancelAutoRepeat => "SMSG_CANCEL_AUTO_REPEAT".into(),
|
||||
ServerPacket::SpellCooldownList { .. } => "SMSG_SPELL_COOLDOWN".into(),
|
||||
ServerPacket::ItemCooldown { .. } => "SMSG_ITEM_COOLDOWN".into(),
|
||||
ServerPacket::ItemEnchantTime { .. } => "SMSG_ITEM_ENCHANT_TIME_UPDATE".into(),
|
||||
ServerPacket::CooldownEvent { .. } => "SMSG_COOLDOWN_EVENT".into(),
|
||||
ServerPacket::ClearCooldown { .. } => "SMSG_CLEAR_COOLDOWN".into(),
|
||||
ServerPacket::CooldownCheat { .. } => "SMSG_COOLDOWN_CHEAT".into(),
|
||||
@@ -1212,7 +1225,7 @@ impl ServerPacket {
|
||||
ServerPacket::MailList { .. } => "SMSG_MAIL_LIST_RESULT".into(),
|
||||
ServerPacket::SendMailResult { .. } => "SMSG_SEND_MAIL_RESULT".into(),
|
||||
ServerPacket::ItemTextQueryResponse { .. } => "SMSG_ITEM_TEXT_QUERY_RESPONSE".into(),
|
||||
ServerPacket::ReceivedMail => "SMSG_RECEIVED_MAIL".into(),
|
||||
ServerPacket::ReceivedMail { .. } => "SMSG_RECEIVED_MAIL".into(),
|
||||
ServerPacket::NextMailTime { .. } => "MSG_QUERY_NEXT_MAIL_TIME".into(),
|
||||
ServerPacket::TradeStatus { .. } => "SMSG_TRADE_STATUS".into(),
|
||||
ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(),
|
||||
|
||||
@@ -428,6 +428,14 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
spell_id,
|
||||
}
|
||||
}
|
||||
opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE => {
|
||||
let (item_guid, slot, seconds) = items::read_item_enchant_time(&mut r)?;
|
||||
ServerPacket::ItemEnchantTime {
|
||||
item_guid,
|
||||
slot,
|
||||
seconds,
|
||||
}
|
||||
}
|
||||
opcode::SMSG_COOLDOWN_EVENT => {
|
||||
let (spell_id, caster) = spellbook::read_cooldown_event(&mut r)?;
|
||||
ServerPacket::CooldownEvent { spell_id, caster }
|
||||
@@ -980,10 +988,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
let (text_id, text) = mail::read_item_text_query_response(&mut r)?;
|
||||
ServerPacket::ItemTextQueryResponse { text_id, text }
|
||||
}
|
||||
opcode::SMSG_RECEIVED_MAIL => {
|
||||
let _ = mail::read_received_mail(&mut r)?;
|
||||
ServerPacket::ReceivedMail
|
||||
}
|
||||
opcode::SMSG_RECEIVED_MAIL => ServerPacket::ReceivedMail {
|
||||
seconds: mail::read_received_mail(&mut r)?,
|
||||
},
|
||||
opcode::MSG_QUERY_NEXT_MAIL_TIME => ServerPacket::NextMailTime {
|
||||
seconds: mail::read_query_next_mail_time(&mut r)?,
|
||||
},
|
||||
|
||||
@@ -251,6 +251,7 @@ pub mod quest_slot_state {
|
||||
// slot arrays are PRIVATE-flagged: the server sends them only for our own player, which is
|
||||
// exactly the consumer (our bags). Each slot is a 2-field guid.
|
||||
const FIELD_ITEM_STACK_COUNT: u16 = 14; // OBJECT_END(6) + 0x8 (the ITEM-block comments are still true)
|
||||
const FIELD_ITEM_ENCHANTMENT: u16 = 22; // OBJECT_END + 0x10, Size 21 = 7 slots × 3 (id/duration/charges)
|
||||
const FIELD_CONTAINER_NUM_SLOTS: u16 = 48; // ITEM_END = 6 + 0x2A (comment says 42 — stale, 6 low)
|
||||
const FIELD_CONTAINER_SLOT_1: u16 = 50; // ITEM_END + 0x2; 36 slots × 2
|
||||
|
||||
|
||||
@@ -44,6 +44,39 @@ impl ObjectFields {
|
||||
pub fn item_text_id(&self) -> Option<u32> {
|
||||
self.get_u32(45)
|
||||
}
|
||||
/// `ITEM_FIELD_ENCHANTMENT + 3*slot` — the enchant id in one of an item OBJECT's **7**
|
||||
/// enchantment slots (`slot < 7`; PERM = 0, TEMP = 1, then the five random-property slots).
|
||||
/// `None` = empty / not sent.
|
||||
///
|
||||
/// This is the item's own array, and it is the FULL one: vmangos
|
||||
/// `UpdateFields_1_12_1.h` gives `ITEM_FIELD_ENCHANTMENT = OBJECT_END + 0x10, Size: 21,
|
||||
/// PUBLIC` = 7 slots × 3 fields (id, duration, charges — `Item.h:117-119`), so field
|
||||
/// `22 + 3*slot`. Distinct from [`Self::player_visible_item_enchant`], the 2-slot
|
||||
/// (PERM, TEMP) copy broadcast on a *unit's* descriptor for everyone else to render from:
|
||||
/// an item object streams only to its owner, so seven slots are readable for our own gear
|
||||
/// and two for anybody else's.
|
||||
///
|
||||
/// **Signed**, because the sign is load-bearing at exactly one consumer and nowhere else: the
|
||||
/// tooltip resolves the `SpellItemEnchantment` row from `abs(id)` and uses the sign only to
|
||||
/// paint the line pure-red instead of green (wow-re `tooltip-content-law.md` §E3).
|
||||
pub fn item_enchant(&self, slot: u8) -> Option<i32> {
|
||||
(slot < 7)
|
||||
.then(|| self.get_u32(FIELD_ITEM_ENCHANTMENT + 3 * u16::from(slot)))?
|
||||
.map(|id| id as i32)
|
||||
.filter(|&id| id != 0)
|
||||
}
|
||||
/// `ITEM_FIELD_ENCHANTMENT + 3*slot + 2` — that slot's remaining enchant **charges**; `0` =
|
||||
/// none. The tooltip appends `" (N Charges)"` to the slot's line when it is nonzero.
|
||||
///
|
||||
/// The triple's MIDDLE field (the duration) is deliberately not exposed: the reference's
|
||||
/// tooltip never reads it. A temporary enchant's countdown comes from
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` instead (`items::Items::enchant_remaining_ms`).
|
||||
pub fn item_enchant_charges(&self, slot: u8) -> u32 {
|
||||
(slot < 7)
|
||||
.then(|| self.get_u32(FIELD_ITEM_ENCHANTMENT + 3 * u16::from(slot) + 2))
|
||||
.flatten()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// `ITEM_FIELD_DURABILITY` — an item's current durability (field 46: chain-locked between the
|
||||
/// tested STACK_COUNT 14 and CONTAINER_NUM_SLOTS 48 = ITEM_END anchors).
|
||||
pub fn item_durability(&self) -> Option<u32> {
|
||||
|
||||
@@ -356,7 +356,12 @@ impl WorldSession {
|
||||
pub fn use_item(&mut self, bag_index: u8, slot: u8, spell_slot: u8) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_USE_ITEM,
|
||||
&messages::use_item(bag_index, slot, spell_slot, None),
|
||||
&messages::use_item(
|
||||
bag_index,
|
||||
slot,
|
||||
spell_slot,
|
||||
messages::UseItemTarget::default(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ impl WorldWriter {
|
||||
bag_index: u8,
|
||||
slot: u8,
|
||||
spell_slot: u8,
|
||||
go_target: Option<u64>,
|
||||
target: messages::UseItemTarget,
|
||||
) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_USE_ITEM,
|
||||
&messages::use_item(bag_index, slot, spell_slot, go_target),
|
||||
&messages::use_item(bag_index, slot, spell_slot, target),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ fn item_query_wire() {
|
||||
|
||||
// CMSG_USE_ITEM (vmangos UseItem::ReadFromWorldPacket): bagIndex, slot, spellSlot, then a
|
||||
// self-shaped target block (u16 mask 0). Backpack slot 1 = bag 255 + player-array slot 23.
|
||||
assert_eq!(messages::use_item(255, 23, 0, None), hx("ff17000000"));
|
||||
assert_eq!(
|
||||
messages::use_item(255, 23, 0, messages::UseItemTarget::SelfImplicit),
|
||||
hx("ff17000000")
|
||||
);
|
||||
|
||||
// The KEY-IN-A-LOCK form (decision 0769): the same three bytes, then a SpellCastTargets with
|
||||
// mask TARGET_FLAG_GAMEOBJECT|TARGET_FLAG_LOCKED (0x0800|0x4000 = 0x4800) and the object's
|
||||
@@ -28,17 +31,39 @@ fn item_query_wire() {
|
||||
// Keyring slot 1 = bag 255 + player-array slot 81 (0x51). The guid 0xF110000C1F00A3B2 has two
|
||||
// zero bytes (indices 2 and 5), so it packs to mask 0xDB + the other six, low byte first.
|
||||
assert_eq!(
|
||||
messages::use_item(255, 81, 0, Some(0xF110_000C_1F00_A3B2)),
|
||||
messages::use_item(
|
||||
255,
|
||||
81,
|
||||
0,
|
||||
messages::UseItemTarget::Object(0xF110_000C_1F00_A3B2)
|
||||
),
|
||||
hx("ff51000048dbb2a31f0c10f1"),
|
||||
);
|
||||
|
||||
// The packed form really does drop zero bytes: guid 1 is mask 0x01 + a single byte, and the
|
||||
// spellSlot ordinal rides in the third byte.
|
||||
assert_eq!(
|
||||
messages::use_item(255, 81, 2, Some(1)),
|
||||
messages::use_item(255, 81, 2, messages::UseItemTarget::Object(1)),
|
||||
hx("ff510200480101")
|
||||
);
|
||||
|
||||
// The UNIT form (decision 0914): an item whose spell binds a unit the way a spell's does — a
|
||||
// bandage, a soulstone — writes TARGET_FLAG_UNIT (0x0002) + the packed guid, the SAME block a
|
||||
// CMSG_CAST_SPELL writes. In the real client one builder serves both opcodes: SendCast
|
||||
// 0x6e54f0 picks 0xab-vs-0x12e from its item discriminator and then writes ArmCast's block.
|
||||
assert_eq!(
|
||||
messages::use_item(255, 24, 0, messages::UseItemTarget::Unit(1)),
|
||||
hx("ff180002000101")
|
||||
);
|
||||
|
||||
// The DEST form (decision 0914): the targeting-cursor commit for a THROWN item — dynamite, a
|
||||
// grenade, the Goblin Mortar. TARGET_FLAG_DEST_LOCATION (0x0040) + three f32 WoW coords, the
|
||||
// same tail cast_spell_at_dest writes. 1.0f32 = 0x3f800000, 2.0 = 0x40000000, 3.0 = 0x40400000.
|
||||
assert_eq!(
|
||||
messages::use_item(19, 3, 1, messages::UseItemTarget::Dest([1.0, 2.0, 3.0])),
|
||||
hx("13030140000000803f0000004000004040")
|
||||
);
|
||||
|
||||
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
|
||||
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
|
||||
|
||||
|
||||
@@ -336,16 +336,22 @@ fn item_text_query_response_wire() {
|
||||
|
||||
#[test]
|
||||
fn received_mail_wire() {
|
||||
// SMSG_RECEIVED_MAIL: one u32, always 0 on vmangos.
|
||||
let body = 0u32.to_le_bytes();
|
||||
match messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap() {
|
||||
ServerPacket::ReceivedMail => {}
|
||||
// SMSG_RECEIVED_MAIL: one f32 delay, the same countdown units as MSG_QUERY_NEXT_MAIL_TIME's
|
||||
// reply (decision 0913 — the real client reads these four bytes as a float). vmangos writes
|
||||
// them as `uint32(0)`, and 0x00000000 *is* 0.0f, so its only value decodes as "waiting now".
|
||||
let vmangos_body = 0u32.to_le_bytes();
|
||||
match messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &vmangos_body).unwrap() {
|
||||
ServerPacket::ReceivedMail { seconds } => assert_eq!(seconds, 0.0),
|
||||
other => panic!("received mail, got {}", other.name()),
|
||||
}
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::ReceivedMail => {}
|
||||
other => panic!("received mail event, got {other:?}"),
|
||||
// A server that sends a real delay round-trips it.
|
||||
for seconds in [0.0f32, 120.0f32] {
|
||||
let body = seconds.to_le_bytes();
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::ReceivedMail { seconds: got } => assert_eq!(got, seconds),
|
||||
other => panic!("received mail event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,12 @@ pub struct InvSlotView {
|
||||
/// The RESOLVED `ITEM_FIELD_CREATOR` name — the equipped-item tooltip's green
|
||||
/// "<Made by %s>" line (see [`super::container::ContainerSlot::creator`], the bag twin).
|
||||
pub creator: Option<String>,
|
||||
/// The instance's resolved enchant slots, in slot order — the doll twin of
|
||||
/// [`super::container::ContainerSlot::enchants`] (decisions 0915/0920). Our own equipped item
|
||||
/// reads all 7 slots off its streamed item object; an INSPECTED player's record carries 7 too
|
||||
/// and the reference renders all 7 from it — but a 1.12 server fills only PERM and TEMP, so in
|
||||
/// practice that is what an inspect hover shows.
|
||||
pub enchants: Vec<super::EnchantView>,
|
||||
}
|
||||
|
||||
/// The inventory-slot snapshot: index 0 = ammo, 1..=19 the equipment slots, 20..=23 the four
|
||||
|
||||
@@ -65,6 +65,32 @@ pub struct ContainerSlot {
|
||||
/// UNLOCKED `0x4`, the wrapped-gift arm WRAPPED `0x8` (see
|
||||
/// [`super::char_stats::InvSlotView::flags`], the doll twin).
|
||||
pub flags: u32,
|
||||
/// The instance's enchant slots, resolved by the app ([`super::EnchantView`]) and in
|
||||
/// enchant-slot order: it joins the id through `SpellItemEnchantment.dbc`'s name column and
|
||||
/// hands over the row's name plus the three facts the line law needs to place and paint it.
|
||||
/// Empty = unenchanted, or the enchant DBC never loaded. Decisions 0915/0920.
|
||||
pub enchants: Vec<super::EnchantView>,
|
||||
}
|
||||
|
||||
/// One enchant slot as the tooltip renders it (wow-re `ui/scratch/tooltip-content-law.md` §E3,
|
||||
/// byte-verified; decision 0920). The app resolves the DBC row; every rule below is the engine's.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct EnchantView {
|
||||
/// `ITEM_FIELD_ENCHANTMENT` slot: **0** permanent · **1** temporary · **2..6** the
|
||||
/// random-property suffix. It decides the colour band — only 0 and 1 are ever coloured.
|
||||
pub slot: u8,
|
||||
/// The enchant id was NEGATIVE. `abs(id)` names the row either way — the sign's only effect is
|
||||
/// to paint slots 0/1 pure-red instead of green (`0x52ca29–0x52ca49`).
|
||||
pub negative: bool,
|
||||
/// The `SpellItemEnchantment` row's name, verbatim (`"Agility +15"`, `"Crusader"`) — the
|
||||
/// reference copies the string with no format at all (`0x52ca8b–0x52caa1`).
|
||||
pub name: String,
|
||||
/// The slot's `ITEM_FIELD_ENCHANTMENT` charges dword — nonzero appends " (N Charges)".
|
||||
pub charges: u32,
|
||||
/// Milliseconds left on a TEMPORARY enchant, from `SMSG_ITEM_ENCHANT_TIME_UPDATE` (the item's
|
||||
/// own duration field is never read for this). `Some` replaces the plain name with the
|
||||
/// countdown phrasing; `None` = no timer, or expired.
|
||||
pub remaining_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// One bag's snapshot: its name, capacity, and occupied slots (1-based).
|
||||
@@ -556,6 +582,7 @@ mod tests {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
// An in-flight slot: the create arrived, the template answer hasn't.
|
||||
@@ -731,6 +758,7 @@ mod tests {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(0, Some(state));
|
||||
|
||||
@@ -460,6 +460,7 @@ mod tests {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
crate::script::container::ContainerState {
|
||||
|
||||
@@ -252,6 +252,7 @@ mod tests {
|
||||
locked: false,
|
||||
equip_slots: vec![1],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots[11] = Some(InvSlotView {
|
||||
bar_placeable: true,
|
||||
@@ -266,6 +267,7 @@ mod tests {
|
||||
locked: false,
|
||||
equip_slots: vec![11, 12], // FINGER1|FINGER2 — the doll↔doll swap fixture
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots[19] = Some(InvSlotView {
|
||||
bar_placeable: true,
|
||||
@@ -280,6 +282,7 @@ mod tests {
|
||||
locked: false,
|
||||
equip_slots: vec![19],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots
|
||||
}
|
||||
@@ -302,6 +305,7 @@ mod tests {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
ContainerState {
|
||||
@@ -443,6 +447,7 @@ mod tests {
|
||||
locked: false,
|
||||
equip_slots: vec![20, 21, 22, 23],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
s.set_inventory_slots(slots);
|
||||
|
||||
@@ -480,6 +485,7 @@ mod tests {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
|
||||
@@ -112,6 +112,11 @@ pub struct ItemTemplateView {
|
||||
pub sell_price: u32,
|
||||
/// `itemset` — nonzero renders the SET block ([`ItemSetView`], asked once by set id).
|
||||
pub item_set: u32,
|
||||
/// `RandomProperty` (template `+0x1b8`) — the item CAN roll a "… of the Bear" suffix. Its one
|
||||
/// consumer is the enchant family's third arm: with no instance to read a roll from, the
|
||||
/// tooltip prints the `<Random enchantment>` placeholder instead of any per-slot line (wow-re
|
||||
/// §1-ENCHANT §E5). Decision 0920.
|
||||
pub random_property: u32,
|
||||
}
|
||||
|
||||
/// The player state the red-line law compares against (decision 0274 P1): pushed by the app
|
||||
|
||||
@@ -107,7 +107,7 @@ pub use char_stats::{
|
||||
weapon_subclass_skill, InvSlotView, InventorySlots, PlayerCombatStats, INVENTORY_SLOT_COUNT,
|
||||
SKILL_UNARMED,
|
||||
};
|
||||
pub use container::{ContainerMove, ContainerSlot, ContainerState, UiCursorMode};
|
||||
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
|
||||
pub use craft::{CraftReagent, CraftRecipe, CraftState};
|
||||
pub use cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell, WorldPick, EQUIPMENT_BAG};
|
||||
pub use death::{DeathAction, DeathUiState};
|
||||
|
||||
@@ -77,6 +77,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
|
||||
@@ -366,3 +366,317 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
"broken (0) paints red"
|
||||
);
|
||||
}
|
||||
|
||||
/// The **enchant lines** (law line 17, decision 0915) — the director's report: an axe carrying
|
||||
/// Enchant Weapon - Agility showed its green glow in the world and said nothing in the tooltip.
|
||||
///
|
||||
/// Three claims at once: the line renders from the instance's resolved enchant text, it sits
|
||||
/// **between the resistances and the durability line** (the law's 16 → 17 → 18), and it is green.
|
||||
/// The control is the template/link hover of the same item — no instance, no enchant, no line.
|
||||
#[test]
|
||||
fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
let mut s = script();
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
count: 1,
|
||||
quality: Some(4),
|
||||
item_id: 22816,
|
||||
durability: Some((105, 105)),
|
||||
// What the app resolved from `ITEM_FIELD_ENCHANTMENT` slot 0 → enchant 2564 →
|
||||
// `SpellItemEnchantment`'s own name string. Slot 0 = permanent, positive id.
|
||||
enchants: vec![EnchantView {
|
||||
slot: 0,
|
||||
name: "Agility +15".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.set_item_template(
|
||||
22816,
|
||||
ItemTemplateView {
|
||||
name: "Hatchet of Sundered Bone".into(),
|
||||
quality: 4,
|
||||
class: 2,
|
||||
resistances: [0, 0, 7, 0, 0, 0],
|
||||
max_durability: 105,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(Slot, "ANCHOR_RIGHT")
|
||||
tt:SetBagItem(0, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
let at = |needle: &str| {
|
||||
texts
|
||||
.iter()
|
||||
.position(|t| t.starts_with(needle))
|
||||
.unwrap_or_else(|| panic!("no {needle} line in {texts:?}"))
|
||||
};
|
||||
assert!(
|
||||
at("+7 Nature Resistance") < at("Agility +15") && at("Agility +15") < at("Durability"),
|
||||
"the enchant line sits between resistances and durability: {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[at("Agility +15")].1,
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
"the enchant line is green"
|
||||
);
|
||||
|
||||
// The control: the same item as a TEMPLATE hover has no instance, so no enchant line.
|
||||
s.run(r#"TT:SetItemById(22816)"#).unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
assert!(
|
||||
!lines.iter().any(|(t, _)| t.starts_with("Agility")),
|
||||
"a template hover carries no instance and so no enchant line: {lines:?}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The **enchant colour bands** — the correction the byte-carve landed (wow-re §1-ENCHANT §E3,
|
||||
/// decision 0920). The colour is per SLOT, not per family: only slots 0 (permanent) and 1
|
||||
/// (temporary) are ever coloured — green for a positive id, the tooltip's OTHER red
|
||||
/// (`0xc0d398 = ffff0000`, distinct from the requirement lines' `ffff2020`) for a negative one —
|
||||
/// and the random-property slots 2..6 are always white whatever the sign. Our first cut painted
|
||||
/// every slot green.
|
||||
#[test]
|
||||
fn enchant_line_colour_is_per_slot_and_sign() {
|
||||
let mut s = script();
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
count: 1,
|
||||
quality: Some(3),
|
||||
item_id: 7777,
|
||||
enchants: vec![
|
||||
EnchantView {
|
||||
slot: 0,
|
||||
name: "Crusader".into(),
|
||||
..Default::default()
|
||||
},
|
||||
EnchantView {
|
||||
slot: 1,
|
||||
name: "Cursed".into(),
|
||||
negative: true,
|
||||
..Default::default()
|
||||
},
|
||||
EnchantView {
|
||||
slot: 3,
|
||||
name: "Stamina +7".into(),
|
||||
..Default::default()
|
||||
},
|
||||
// A suffix slot with a NEGATIVE id is still white — the band, not the sign, rules.
|
||||
EnchantView {
|
||||
slot: 4,
|
||||
name: "Spirit +3".into(),
|
||||
negative: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.set_item_template(
|
||||
7777,
|
||||
ItemTemplateView {
|
||||
name: "Test Blade".into(),
|
||||
quality: 3,
|
||||
class: 2,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(Slot, "ANCHOR_RIGHT")
|
||||
tt:SetBagItem(0, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
let color_of = |needle: &str| {
|
||||
lines
|
||||
.iter()
|
||||
.find(|(t, _)| t.starts_with(needle))
|
||||
.unwrap_or_else(|| panic!("no {needle} line in {lines:?}"))
|
||||
.1
|
||||
};
|
||||
assert_eq!(color_of("Crusader"), [0.0, 1.0, 0.0, 1.0], "slot 0, id > 0");
|
||||
assert_eq!(
|
||||
color_of("Cursed"),
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
"slot 1, id < 0 → the pure red 0xc0d398, NOT the requirement red"
|
||||
);
|
||||
assert_eq!(
|
||||
color_of("Stamina +7"),
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
"a suffix slot is white"
|
||||
);
|
||||
assert_eq!(
|
||||
color_of("Spirit +3"),
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
"…and stays white even with a negative id"
|
||||
);
|
||||
}
|
||||
|
||||
/// The temporary enchant's countdown REPLACES the name in the same line (never a second one), and
|
||||
/// the charges suffix rides after it — §E3's `0x52fa50` bucket ladder and the `" (%s)"` join. The
|
||||
/// countdown's source is `SMSG_ITEM_ENCHANT_TIME_UPDATE`, so a slot with no packet shows the bare
|
||||
/// name: that is the control here.
|
||||
#[test]
|
||||
fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
let mut s = script();
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
count: 1,
|
||||
quality: Some(1),
|
||||
item_id: 7777,
|
||||
enchants: vec![
|
||||
EnchantView {
|
||||
slot: 1,
|
||||
name: "Rockbiter Weapon".into(),
|
||||
remaining_ms: Some(275_000), // 4 min 35 s → "(5 min)"
|
||||
charges: 5,
|
||||
..Default::default()
|
||||
},
|
||||
EnchantView {
|
||||
slot: 0,
|
||||
name: "Crusader".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.set_item_template(
|
||||
7777,
|
||||
ItemTemplateView {
|
||||
name: "Test Blade".into(),
|
||||
quality: 1,
|
||||
class: 2,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(Slot, "ANCHOR_RIGHT")
|
||||
tt:SetBagItem(0, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
texts.contains(&"Rockbiter Weapon (5 min) (5 Charges)"),
|
||||
"one line: name, countdown, charges — got {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.contains(&"Crusader"),
|
||||
"a slot with no packet keeps the bare name — got {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<Random enchantment>` (§E5) — the template-only placeholder: a random-property item with NO
|
||||
/// instance to read a roll from prints it, green, and the per-slot lines and this one are mutually
|
||||
/// exclusive by construction. The control is the same template hovered as a real enchanted
|
||||
/// instance: the roll is known, so the placeholder gives way to the slot lines.
|
||||
#[test]
|
||||
fn random_property_template_hover_shows_the_placeholder() {
|
||||
let mut s = script();
|
||||
s.set_item_template(
|
||||
8888,
|
||||
ItemTemplateView {
|
||||
name: "Bloodrazor".into(),
|
||||
quality: 2,
|
||||
class: 2,
|
||||
random_property: 42,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(Slot, "ANCHOR_RIGHT")
|
||||
tt:SetItemById(8888)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
let placeholder = lines
|
||||
.iter()
|
||||
.find(|(t, _)| t == "<Random enchantment>")
|
||||
.expect("the template hover shows the placeholder");
|
||||
assert_eq!(placeholder.1, [0.0, 1.0, 0.0, 1.0], "green");
|
||||
|
||||
// With a real instance (the roll is known) the placeholder is gone and the slots print.
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
count: 1,
|
||||
quality: Some(2),
|
||||
item_id: 8888,
|
||||
enchants: vec![EnchantView {
|
||||
slot: 2,
|
||||
name: "Stamina +7".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
0,
|
||||
Some(ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
s.run(r#"TT:SetBagItem(0, 1)"#).unwrap();
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
!texts.contains(&"<Random enchantment>") && texts.contains(&"Stamina +7"),
|
||||
"an instance's known roll replaces the placeholder — got {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
creator: s.creator.clone(),
|
||||
has_text: false,
|
||||
flags: s.flags,
|
||||
enchants: s.enchants.clone(),
|
||||
// `SetInventoryItem 0x532ee0` also has p6=0 legs (`0x533106`,
|
||||
// `0x5332ad`) — the "this binding can never emit OPENABLE" claim
|
||||
// is dead here too (wow-re `right-click-open.md` §1.2). Which leg
|
||||
@@ -390,6 +391,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
creator: s.creator.clone(),
|
||||
has_text: s.readable,
|
||||
flags: s.flags,
|
||||
enchants: s.enchants.clone(),
|
||||
// p6 == 0 ⇔ no running cooldown. A clam shows the green line; the
|
||||
// same clam mid-cooldown would show ITEM_COOLDOWN_TIME instead
|
||||
// (that line has no feed here yet — a separate, pre-existing gap).
|
||||
|
||||
@@ -21,6 +21,10 @@ pub(super) const QUALITY_RGB: [[f32; 3]; 7] = [
|
||||
pub(super) const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
pub(super) const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
pub(super) const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||||
/// The tooltip's OTHER red — `0xc0d398 = ffff0000`, a pure red distinct from the (255,32,32) the
|
||||
/// requirement lines wear. Two lines use it, both in the enchant family: a **negative** enchant id
|
||||
/// in slot 0/1, and ITEM_ENCHANT_DISCLAIMER (wow-re §1-ENCHANT §E3/§E4).
|
||||
pub(super) const ENCHANT_RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
pub(super) const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||||
pub(super) const GRAY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0];
|
||||
/// The owned set member's pale cream — byte-read `0xc0d368 = ffffff97` (writer `0x529050`).
|
||||
|
||||
@@ -29,6 +29,10 @@ pub(super) struct ItemInstance {
|
||||
/// Instance `ITEM_FIELD_FLAGS` — the openable lock sub-gate reads UNLOCKED `0x4`
|
||||
/// (`0x52e30c`) and the wrapped-gift arm WRAPPED `0x8` (`0x52e31d`).
|
||||
pub flags: u32,
|
||||
/// The instance's enchant slots, app-resolved and in slot order (law line 17 / §1-ENCHANT,
|
||||
/// decisions 0915/0920) — see [`crate::script::EnchantView`]. Empty on an unenchanted item and
|
||||
/// on every template/link source (no instance, nothing enchanted).
|
||||
pub enchants: Vec<crate::script::EnchantView>,
|
||||
/// May this source emit the ITEM_OPENABLE line? = the reference's `p6 == 0` — "this tooltip
|
||||
/// carries **no** caller-supplied instance block" (`[this+0x440]`, tested at `0x52e2e8`: when
|
||||
/// it is set, the builder evaluates only READABLE and skips the openable tree entirely).
|
||||
@@ -54,8 +58,9 @@ pub(super) struct ItemInstance {
|
||||
/// Render one item template into the tooltip — the BYTE-VERIFIED emission law of the shared
|
||||
/// renderer `0x52b650` (wow-re `ui/scratch/tooltip-content-law.md`, §5-cross-checked 2026-07-10;
|
||||
/// the proficiency-cell and SET legs byte-read directly 2026-07-11; the creator/readable
|
||||
/// instance tail byte-read 2026-07-20), minus the instance-only families still unfed
|
||||
/// (soulbound override, enchants, cooldown-remaining, the gift-wrap family).
|
||||
/// instance tail byte-read 2026-07-20; the enchant lines of line 17 fed 2026-08-03, decision
|
||||
/// 0915), minus the instance-only families still unfed (soulbound override, cooldown-remaining,
|
||||
/// the gift-wrap family).
|
||||
pub(super) fn render_view(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -276,6 +281,58 @@ pub(super) fn render_view(
|
||||
))?;
|
||||
}
|
||||
}
|
||||
// **Line 17 — the enchant family** (wow-re `tooltip-content-law.md` §1-ENCHANT, byte-carved
|
||||
// 2026-08-03 on this lane's dispatch; decisions 0915/0920). One contiguous block
|
||||
// `[0x52c991, 0x52cc69)` between the resistances and the durability precompute, and three arms
|
||||
// that are mutually exclusive by construction — the per-slot loop falls through to the
|
||||
// proposed-enchant pair and jumps the block's end, so RANDOM_ENCHANT is reachable only when
|
||||
// there was no id source at all (§E1).
|
||||
//
|
||||
// The **colour is per slot**, and this is the correction the carve landed (§E3): the value is a
|
||||
// computed local, defaulting to WHITE, overwritten **only for slots 0 and 1** — green
|
||||
// `0xc0d3ac` for a positive id, pure-red `0xc0d398` for a negative one. Slots 2..6 — the
|
||||
// random-property suffix enchants — are **always white**, whatever the sign. (Our first cut
|
||||
// painted every slot green.) The sign never picks a different DBC row; the app already
|
||||
// resolved that off `abs(id)`.
|
||||
//
|
||||
// Two gates sit above the loop. **ITEM_SIGNABLE** (template Flags bit `0x2000`, a petition or
|
||||
// guild charter) forces every id to 0 with no fallback (`0x52c9e0: test ah,0x20`) — such an
|
||||
// item shows no enchant line even if its instance carries ids. And with **no id source at all**
|
||||
// the block instead prints the template-only `ITEM_RANDOM_ENCHANT` placeholder (§E5).
|
||||
let signable = v.flags & 0x2000 != 0;
|
||||
let enchant_slots = match signable {
|
||||
true => &[][..],
|
||||
false => inst.map(|i| i.enchants.as_slice()).unwrap_or_default(),
|
||||
};
|
||||
// "No id source" is the reference's own three-way fork (§E1): a wrapped gift, or no item
|
||||
// object AND no caller-supplied instance block. Ours reduces to "this hover carries no
|
||||
// instance" — a template or link hover — plus the wrapped-gift bit, which is the same set.
|
||||
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
|
||||
if no_id_source && !signable && v.random_property != 0 {
|
||||
add(("<Random enchantment>".into(), GREEN))?;
|
||||
}
|
||||
for e in enchant_slots {
|
||||
let color = match (e.slot < 2, e.negative) {
|
||||
(true, false) => GREEN,
|
||||
(true, true) => ENCHANT_RED,
|
||||
(false, _) => WHITE,
|
||||
};
|
||||
// A TEMPORARY enchant's countdown REPLACES the plain name in the same line and keeps that
|
||||
// colour — it is never a second line (§E3). The bucket ladder (day/hour/min/sec) and its
|
||||
// ceil-vs-truncate split are [`enchant_time_left`]'s; its source is
|
||||
// `SMSG_ITEM_ENCHANT_TIME_UPDATE`, never the item's own duration field.
|
||||
let mut text = match e.remaining_ms {
|
||||
Some(ms) => enchant_time_left(&e.name, ms),
|
||||
None => e.name.clone(),
|
||||
};
|
||||
// " (N Charges)" — the slot's own charges dword through ITEM_SPELL_CHARGES, then the
|
||||
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`). Only an owned item object carries
|
||||
// charges; the session/inspect legs ship ids alone, so this is naturally absent there.
|
||||
if e.charges != 0 {
|
||||
text.push_str(&format!(" ({})", charges_phrase(e.charges)));
|
||||
}
|
||||
add((text, color))?;
|
||||
}
|
||||
if let Some((cur, max)) = inst.and_then(|i| i.durability).filter(|&(_, max)| max > 0) {
|
||||
// Red iff BROKEN (durability 0) — the byte law (wow-re ui.md tooltip content law:
|
||||
// "durability (red iff broken==0)", the AddLine colour pointer `0xc0d390`, the same
|
||||
@@ -366,10 +423,8 @@ pub(super) fn render_view(
|
||||
};
|
||||
addw((format!("{prefix}{text}"), GREEN))?;
|
||||
}
|
||||
match v.charges {
|
||||
1 => add(("1 Charge".into(), WHITE))?,
|
||||
n if n > 1 => add((format!("{n} Charges"), WHITE))?,
|
||||
_ => {}
|
||||
if v.charges > 0 {
|
||||
add((charges_phrase(v.charges as u32), WHITE))?;
|
||||
}
|
||||
// The item-SET block (§22, ABOVE the compact cut), byte-read at the builder's
|
||||
// `0x52d8a0..0x52e0f5`: a blank gold line, the gold "name (owned/total)" header, the
|
||||
@@ -472,3 +527,77 @@ pub(super) fn render_view(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A temporary enchant's line text — the name with its countdown, the reference's own bucket
|
||||
/// ladder (wow-re `tooltip-content-law.md` §E3 → `0x52fa50`, byte-verified): a runtime key
|
||||
/// `ITEM_ENCHANT_TIME_LEFT_<DAYS|HOURS|MIN|SEC>` chosen by the largest unit that fits, with the
|
||||
/// count taken as **ceil** in the day/hour/minute arms and **truncated** in the seconds arm. The
|
||||
/// day/hour keys carry a `_P1` plural twin in the shipped GlobalStrings; minutes and seconds do
|
||||
/// not, so those read "(1 min)" at one minute exactly as they read "(9 min)".
|
||||
///
|
||||
/// Templates are the shipped enUS values (`Interface\FrameXML\GlobalStrings.lua:2406-2411`),
|
||||
/// inlined like every other string in this builder.
|
||||
fn enchant_time_left(name: &str, ms: u64) -> String {
|
||||
const SEC: u64 = 1_000;
|
||||
const MIN: u64 = 60 * SEC;
|
||||
const HOUR: u64 = 60 * MIN;
|
||||
const DAY: u64 = 24 * HOUR;
|
||||
let ceil = |unit: u64| ms.div_ceil(unit);
|
||||
match ms {
|
||||
_ if ms >= DAY => match ceil(DAY) {
|
||||
1 => format!("{name} (1 day)"),
|
||||
n => format!("{name} ({n} days)"),
|
||||
},
|
||||
_ if ms >= HOUR => match ceil(HOUR) {
|
||||
1 => format!("{name} (1 hour)"),
|
||||
n => format!("{name} ({n} hrs)"),
|
||||
},
|
||||
_ if ms >= MIN => format!("{name} ({} min)", ceil(MIN)),
|
||||
_ => format!("{name} ({} sec)", ms / SEC),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` — "%d Charge" / "%d Charges" (`GlobalStrings.lua:2448-2449`, the `_P1`
|
||||
/// plural twin). One rule for both consumers: the standalone charges line (law line 21) and the
|
||||
/// enchant line's ` (…)` suffix (§E3).
|
||||
fn charges_phrase(n: u32) -> String {
|
||||
match n {
|
||||
1 => "1 Charge".into(),
|
||||
n => format!("{n} Charges"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The countdown's bucket ladder (wow-re §1-ENCHANT §E3 → `0x52fa50`): the largest unit that
|
||||
/// fits wins, the count is **ceil** in the day/hour/minute arms and **truncated** in seconds,
|
||||
/// and the day/hour arms have singular/plural twins while minutes and seconds do not.
|
||||
#[test]
|
||||
fn enchant_countdown_buckets_and_rounding() {
|
||||
let t = |ms| enchant_time_left("Rockbiter", ms);
|
||||
// Seconds truncate: 1900 ms is "1 sec", not 2.
|
||||
assert_eq!(t(1_900), "Rockbiter (1 sec)");
|
||||
assert_eq!(t(59_999), "Rockbiter (59 sec)");
|
||||
// Minutes ceil: one second past 4 minutes already reads 5.
|
||||
assert_eq!(t(60_000), "Rockbiter (1 min)");
|
||||
assert_eq!(t(241_000), "Rockbiter (5 min)");
|
||||
// Hours ceil, with the shipped plural spelling ("hrs", not "hours").
|
||||
assert_eq!(t(3_600_000), "Rockbiter (1 hour)");
|
||||
assert_eq!(t(3_600_001), "Rockbiter (2 hrs)");
|
||||
// Days ceil.
|
||||
assert_eq!(t(86_400_000), "Rockbiter (1 day)");
|
||||
assert_eq!(t(86_400_001), "Rockbiter (2 days)");
|
||||
// Zero never reaches here (the app drops an expired timer), but it must not panic.
|
||||
assert_eq!(t(0), "Rockbiter (0 sec)");
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin — one rule, both consumers (the standalone
|
||||
/// charges line and the enchant line's suffix).
|
||||
#[test]
|
||||
fn charges_phrase_picks_the_plural() {
|
||||
assert_eq!(charges_phrase(1), "1 Charge");
|
||||
assert_eq!(charges_phrase(5), "5 Charges");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,10 +104,11 @@
|
||||
|
||||
-- ref-Minimap.xml MiniMapMailFrame's OnEvent, verbatim logic (l.281-289): UPDATE_PENDING_MAIL
|
||||
-- is the ONLY event the reference registers on this frame — no MAIL_INBOX_UPDATE/MAIL_CLOSED
|
||||
-- exists there despite the icon logically depending on inbox state. benilla's arrival feed
|
||||
-- (`ui_mail::feed_mail`, decision 0544 P3) fires UPDATE_PENDING_MAIL on every HasNewMail()
|
||||
-- value transition, which already covers a read-to-empty inbox — so mirroring just this one
|
||||
-- event is faithful, not a simplification.
|
||||
-- exists there despite the icon logically depending on inbox state. That is not an oversight
|
||||
-- to paper over: reading your mail clears the icon through the pending-mail countdown, never
|
||||
-- through the inbox (opening a letter arms a deferred refresh; closing the mailbox re-asks
|
||||
-- the server). `ui_mail::MailPending` models that countdown and fires UPDATE_PENDING_MAIL at
|
||||
-- exactly the reference's three signal sites — decision 0913.
|
||||
function MiniMapMailFrame_OnEvent()
|
||||
if ( event == "UPDATE_PENDING_MAIL" ) then
|
||||
if ( HasNewMail() ) then
|
||||
|
||||
@@ -695,6 +695,7 @@ type AuraParts<'w, 's> = Query<
|
||||
Option<&'static InteriorLit>,
|
||||
Option<&'static RenderFade>,
|
||||
Has<PendingAppearFade>,
|
||||
Has<crate::model_render::FarSideOfWater>,
|
||||
),
|
||||
// Disjointness for the card pass (both want `&mut MeshTag`); a card never carries
|
||||
// `FadeMaterials`, so this filter excludes nothing that would otherwise match — the same
|
||||
@@ -729,6 +730,7 @@ pub(crate) fn apply_aura_alpha(
|
||||
mut roots: Query<(Entity, &mut AuraNodes)>,
|
||||
children_of: Query<&Children>,
|
||||
mut parts: AuraParts,
|
||||
far_twins: Res<crate::model_render::FarSideTwins>,
|
||||
mut cards: Query<(
|
||||
&crate::billboard::BillboardCard,
|
||||
&mut MeshTag,
|
||||
@@ -752,6 +754,7 @@ pub(crate) fn apply_aura_alpha(
|
||||
now,
|
||||
&children_of,
|
||||
&mut parts,
|
||||
&far_twins,
|
||||
&mut reauthor,
|
||||
&mut walked,
|
||||
);
|
||||
@@ -803,17 +806,19 @@ fn author_cards(
|
||||
/// property that its attached models render off (the same reason
|
||||
/// [`crate::model_fade::apply_despawn_fade`] and the self feather both walk descendants, not
|
||||
/// children).
|
||||
#[allow(clippy::too_many_arguments)] // the author's full channel set, threaded down the walk
|
||||
fn author_descendants(
|
||||
entity: Entity,
|
||||
alpha: f32,
|
||||
now: f32,
|
||||
children_of: &Query<&Children>,
|
||||
parts: &mut AuraParts,
|
||||
far_twins: &crate::model_render::FarSideTwins,
|
||||
reauthor: &mut crate::interior::InteriorReauthor,
|
||||
walked: &mut bevy::ecs::entity::EntityHashSet,
|
||||
) {
|
||||
walked.insert(entity);
|
||||
if let Ok((fm, mut tag, mut mat, anim, lit, fade, pending)) = parts.get_mut(entity) {
|
||||
if let Ok((fm, mut tag, mut mat, anim, lit, fade, pending, far_side)) = parts.get_mut(entity) {
|
||||
// A part still WAITING on its appear-fade is deliberately invisible until the ramp arms (its
|
||||
// tag alpha is seeded near-zero at spawn), so authoring it would flash a streaming-in unit's
|
||||
// geometry at the aura alpha for the pending window. Every other author of this channel
|
||||
@@ -836,8 +841,14 @@ fn author_descendants(
|
||||
tag.0 = bits;
|
||||
}
|
||||
// The INSTANCE alpha decides the pass; a settled-opaque release hands the law's steady
|
||||
// material back and asks the classifier to re-author, so nothing is left on a stale one.
|
||||
let want = fm.material_for(lit, alpha < 1.0).clone();
|
||||
// material back and asks the classifier to re-author, so nothing is left on a stale
|
||||
// one. The water-plane axis composes here like everywhere else (`far_resolved`).
|
||||
let want = crate::model_render::far_resolved(
|
||||
fm.material_for(lit, alpha < 1.0),
|
||||
far_side,
|
||||
far_twins,
|
||||
)
|
||||
.clone();
|
||||
if mat.0 != want {
|
||||
mat.0 = want;
|
||||
if alpha >= 1.0 && lit.is_some() {
|
||||
@@ -850,7 +861,16 @@ fn author_descendants(
|
||||
}
|
||||
if let Ok(kids) = children_of.get(entity) {
|
||||
for kid in kids {
|
||||
author_descendants(*kid, alpha, now, children_of, parts, reauthor, walked);
|
||||
author_descendants(
|
||||
*kid,
|
||||
alpha,
|
||||
now,
|
||||
children_of,
|
||||
parts,
|
||||
far_twins,
|
||||
reauthor,
|
||||
walked,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ fn app_with_chains() -> App {
|
||||
app.add_message::<crate::creature_anim::SpellKitFx>();
|
||||
app.add_message::<crate::creature_anim::SpellKitSound>();
|
||||
app.add_message::<AuraProc>();
|
||||
// The water-plane twin map the alpha author composes with (empty here — no water in a fixture).
|
||||
app.init_resource::<crate::model_render::FarSideTwins>();
|
||||
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
|
||||
HashMap::from([
|
||||
(
|
||||
@@ -489,6 +491,8 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.init_resource::<crate::interior::InteriorReauthor>();
|
||||
// The water-plane twin map the author composes with (empty — no water in a fixture).
|
||||
app.init_resource::<crate::model_render::FarSideTwins>();
|
||||
app.add_systems(Update, apply_aura_alpha);
|
||||
|
||||
let fm = FadeMaterials {
|
||||
|
||||
@@ -1121,6 +1121,7 @@ fn seed_bag_window(
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
}
|
||||
};
|
||||
// ~5 items scattered across the 16 slots, a mix of stacked and unstacked, under their real
|
||||
@@ -1209,6 +1210,7 @@ fn seed_equipped_bags(
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
};
|
||||
|
||||
// Seed the four equipped-bag EQUIPMENT slots (live inv ids 20..23 = Bag0Slot..Bag3Slot) with a
|
||||
|
||||
@@ -75,7 +75,10 @@ impl Plugin for CloudsPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
tick_clouds,
|
||||
// After the submersion verdict: the surfacing edge (wet→dry) must fire the
|
||||
// full rebuild the SAME frame the dome un-hides, or the first visible frame
|
||||
// still shows the submerged-era tile.
|
||||
tick_clouds.after(crate::liquid::SubmersionVerdict),
|
||||
// After the resolve: the dome and the painted skybox must agree WITHIN a frame,
|
||||
// or one frame draws both (the ordering `crate::sky`'s gate already takes) —
|
||||
// and after the submersion verdict, for the same one-frame-agreement reason.
|
||||
@@ -96,6 +99,7 @@ impl Plugin for CloudsPlugin {
|
||||
/// incremental 32-row band scroll (or the frozen capture clock) — and re-upload the colored
|
||||
/// RGBA texels when they changed (the reference's per-regen `0x58ac70` upload of the `0x6cfb00`
|
||||
/// color buffer, Addendum A §3).
|
||||
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
fn tick_clouds(
|
||||
mut cov: ResMut<CloudCoverage>,
|
||||
light: Res<WowLighting>,
|
||||
@@ -103,7 +107,19 @@ fn tick_clouds(
|
||||
clock: Option<Res<layer::CloudLayer>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut materials: ResMut<Assets<layer::CloudMaterial>>,
|
||||
underwater: Res<crate::liquid::Underwater>,
|
||||
mut was_submerged: Local<bool>,
|
||||
) {
|
||||
// The surfacing edge — the reference's wet→dry detector `0x680ac3` fires `0x6d2210(1)`, the
|
||||
// FULL-rebuild selector (`bl!=0` → `0x6cff90`), the frame the eye leaves the liquid
|
||||
// (byte-VERIFIED, wow-re `water-frame-straddle.md` §4b + `cloud-coverage-pipeline.md` §0).
|
||||
// Without it the incremental 32-row scroll restores the field band-by-band over ~0.4 s+ —
|
||||
// the sky pass returns instantly (0902's gate) but the clouds creep back in behind it,
|
||||
// the "delayed pop-in" the director sighted. Dry→wet takes no edge, exactly like the
|
||||
// reference: the sky pass is skipped while submerged, so nobody sees the tile.
|
||||
let submerged = underwater.0.any();
|
||||
let surfaced = *was_submerged && !submerged;
|
||||
*was_submerged = submerged;
|
||||
let density = light.cloud_density;
|
||||
// The color-pass inputs (`0x6cfb00`'s per-frame setup), from the resolved lighting.
|
||||
let frame = kernel::CloudFrame {
|
||||
@@ -114,13 +130,16 @@ fn tick_clouds(
|
||||
glow_dir: light.cloud_glow_dir,
|
||||
glow_track: light.cloud_glow_track,
|
||||
};
|
||||
if surfaced && std::env::var_os("WOW_CLOUD_DUMP").is_some() {
|
||||
eprintln!("[cloud] surfaced -> full rebuild (C {density:.3})");
|
||||
}
|
||||
if std::env::var_os("WOW_CLOUD_DUMP").is_some() && cov.last_frame != Some(frame) {
|
||||
eprintln!(
|
||||
"[cloud] C {density:.3} sun {:?} slope {:?} gbase {:?} bcc {:.2} glow_dir {:?} track {:.2}",
|
||||
frame.sun, frame.slope, frame.gbase, frame.bcc, frame.glow_dir, frame.glow_track
|
||||
);
|
||||
}
|
||||
let changed = if !cov.primed || (cov.frozen && density != cov.last_density) {
|
||||
let changed = if !cov.primed || surfaced || (cov.frozen && density != cov.last_density) {
|
||||
cov.primed = true;
|
||||
cov.last_density = density;
|
||||
cov.last_frame = Some(frame);
|
||||
|
||||
@@ -43,6 +43,11 @@ pub(super) fn apply_model_visibility(
|
||||
// (`[0xc7b748]` is the branch, not a participant). Without this exemption, standing inside
|
||||
// Stratholme would window-cull Stratholme's own walls.
|
||||
claim: Res<crate::wmo_portal::CameraInteriorClaim>,
|
||||
// The water-plane classification (`model_render::classify_water_side`) — read-only here: this
|
||||
// system owns the DoodadFade handle pick, so the far-side axis composes INTO that pick (the
|
||||
// marker + the twin map) instead of a second writer re-swapping the handle it just wrote —
|
||||
// the exact fight decision 0025's one-authority law exists to prevent.
|
||||
far_twins: Res<crate::model_render::FarSideTwins>,
|
||||
mut q: Query<(
|
||||
&ModelPart,
|
||||
&GlobalTransform,
|
||||
@@ -54,6 +59,7 @@ pub(super) fn apply_model_visibility(
|
||||
Option<&WmoGroupVis>,
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
Has<crate::exterior_cull::ExteriorScene>,
|
||||
Has<crate::model_render::FarSideOfWater>,
|
||||
)>,
|
||||
// The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a
|
||||
// `WmoGroupVis` like every other piece of their group but no `ModelPart` — they are not model
|
||||
@@ -87,7 +93,7 @@ pub(super) fn apply_model_visibility(
|
||||
// serial walk alone blew half the 16.7 ms budget (the Stormwind fps hunt). Every write below
|
||||
// is change-gated, so the steady state is a pure read fan-out.
|
||||
q.par_iter_mut().for_each(
|
||||
|(part, xf, mut vis, fade, tag, mat, aabb, group_vis, mat_anim, exterior)| {
|
||||
|(part, xf, mut vis, fade, tag, mat, aabb, group_vis, mat_anim, exterior, far_side)| {
|
||||
let toggled_on =
|
||||
m.kind_visible[part.kind.index()] && m.blend_visible[blend_index(part.blend)];
|
||||
// `farclip` is the *world-doodad/WMO* draw distance only. Creatures/GameObjects come from the
|
||||
@@ -212,11 +218,22 @@ pub(super) fn apply_model_visibility(
|
||||
}
|
||||
if let Some(f) = fade {
|
||||
if let Some(mut mat) = mat {
|
||||
let want = if fade_alpha < 1.0 {
|
||||
let base = if fade_alpha < 1.0 {
|
||||
&f.blend
|
||||
} else {
|
||||
&f.cutout
|
||||
};
|
||||
// A feathering doodad on the eye's far side of the water plane takes the far
|
||||
// twin of the SAME pick (the water-plane interleave, `sky_order::FAR_SIDE_BIAS`)
|
||||
// — the classification is the marker's, the pick stays this system's. The
|
||||
// cutout never composes: an opaque draw settles against the water by depth. A
|
||||
// map miss = the twin isn't built yet (classify runs on transparent draws
|
||||
// only); keep the base and pick it up next frame.
|
||||
let want = if far_side && fade_alpha < 1.0 {
|
||||
far_twins.far_of(base).unwrap_or(base)
|
||||
} else {
|
||||
base
|
||||
};
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
|
||||
@@ -66,9 +66,10 @@ mod item_glow;
|
||||
use item_glow::{attach_item_glows, ItemGlows};
|
||||
|
||||
/// Mounts (decision 0441): the `UNIT_FIELD_MOUNTDISPLAYID` → second-creature-visual projection —
|
||||
/// the mount child + seat components and the transition's diff-and-rebuild.
|
||||
/// the mount child + seat components and the transition's **re-seat** (B199: the reference
|
||||
/// re-parents the rider's model onto the mount, it never rebuilds it).
|
||||
pub(crate) mod mount;
|
||||
use mount::refresh_mounts;
|
||||
use mount::reseat_mounts;
|
||||
|
||||
/// Live descriptor appearance (decision 0695): a `Values` delta moving the display id swaps the
|
||||
/// model in place (druid forms, GM morphs — ledger B69/F04) and one moving `SCALE_X` eases the
|
||||
@@ -470,9 +471,12 @@ impl Plugin for EntitiesPlugin {
|
||||
// on the same parts, the equipment geosets re-selected, every attachment left
|
||||
// alone (decision 0835, the reference's own shape).
|
||||
attach::redress_player_looks,
|
||||
// A mount transition does the same (decision 0441): the field diff tears the
|
||||
// visual down, attach rebuilds it mounted (or dismounted) next frame(s).
|
||||
refresh_mounts,
|
||||
// A mount transition does the same, and for the same reason (B199): the
|
||||
// field diff **re-seats** the standing rig — onto the mount's attachment-0
|
||||
// joint, or back onto its own frame — where it used to tear the whole rider
|
||||
// down and let attach rebuild it. The reference re-parents the body model
|
||||
// (`0x712f70`/`0x713020`); it never re-creates it.
|
||||
reseat_mounts,
|
||||
// A live display-id / scale change (decision 0695): the display swap is the
|
||||
// same teardown-and-rebuild; the scale change arms the reference's 2 s ease.
|
||||
// The rig heal (decision 0863) rides the same nest (Bevy's 20-tuple ceiling):
|
||||
@@ -557,6 +561,26 @@ impl Plugin for EntitiesPlugin {
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
// The water-plane interleave's MESH half (the sibling of the effect half in
|
||||
// `particles::sim`): every transparent M2 batch classifies against the water plane
|
||||
// and takes its far-side twin, so the surface paints over a submerged model's
|
||||
// sheen/glow layers. Two lanes: entities whose handle the Visibility authority owns
|
||||
// (the `DoodadFade` holders it pins every frame, decision 0025) get a MARKER the
|
||||
// authority composes into its own pick — so classify runs before `ModelVisSet`, and
|
||||
// the pick sees this frame's side; everything else (equipment, spell-fx, fade twins)
|
||||
// is swapped here directly, after the fade resolve whose choice it re-derives from
|
||||
// the current handle. The self feather stays deliberately unordered: it only writes
|
||||
// while the camera is within arm's reach of the player's own parts, and the 0905
|
||||
// waterline snap keeps the eye a full band off the surface — eye and part are then
|
||||
// on the same side of the plane, where both writers agree on the near handle.
|
||||
.init_resource::<crate::model_render::FarSideTwins>()
|
||||
.add_systems(
|
||||
Update,
|
||||
crate::model_render::classify_water_side
|
||||
.after(crate::liquid::SubmersionVerdict)
|
||||
.after(apply_render_fade)
|
||||
.before(crate::debug_panel::ModelVisSet),
|
||||
)
|
||||
// …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
|
||||
@@ -668,25 +692,26 @@ fn setup_entities(
|
||||
}
|
||||
Err(e) => warn!("item displays unavailable, units hold nothing: {e:#}"),
|
||||
}
|
||||
// The item/enchant glow chain (decision 0805) — both tables or neither: a glow needs the
|
||||
// ItemVisuals join to name a model and the enchant column to know a temporary one. Absent,
|
||||
// weapons simply draw unadorned.
|
||||
match (
|
||||
benilla_formats::load_item_visual_catalog(&mut chain),
|
||||
benilla_formats::load_enchant_visual_catalog(&mut chain),
|
||||
) {
|
||||
(Ok(visuals), Ok(enchants)) => {
|
||||
// The glow chain's ItemVisuals join (decision 0805). Absent, weapons draw unadorned.
|
||||
match benilla_formats::load_item_visual_catalog(&mut chain) {
|
||||
Ok(visuals) => {
|
||||
info!("item visual catalog: {} glow rows", visuals.len());
|
||||
commands.insert_resource(ItemGlows::new(visuals));
|
||||
}
|
||||
Err(e) => warn!("item visuals unavailable, weapons never glow: {e:#}"),
|
||||
}
|
||||
// `SpellItemEnchantment`'s two consumer columns — the enchant half of that glow chain, and
|
||||
// the tooltip's enchant line (decision 0915). One load, one resource, both lanes.
|
||||
match benilla_formats::load_enchant_catalog(&mut chain) {
|
||||
Ok(enchants) => {
|
||||
info!(
|
||||
"item visual catalog: {} glow rows, {} enchants with a glow",
|
||||
visuals.len(),
|
||||
enchants.len()
|
||||
"enchant catalog: {} named, {} carrying a glow",
|
||||
enchants.name_count(),
|
||||
enchants.visual_count()
|
||||
);
|
||||
commands.insert_resource(ItemGlows::new(visuals, enchants));
|
||||
}
|
||||
(v, e) => {
|
||||
let err = v.err().or(e.err());
|
||||
warn!("item visuals unavailable, weapons never glow: {err:#?}");
|
||||
commands.insert_resource(crate::items::Enchants(enchants));
|
||||
}
|
||||
Err(e) => warn!("enchants unavailable: no enchant glow, no enchant line: {e:#}"),
|
||||
}
|
||||
match benilla_formats::load_durability_tables(&mut chain) {
|
||||
Ok(tables) => commands.insert_resource(crate::ui_merchant::RepairTables(tables)),
|
||||
|
||||
@@ -257,10 +257,7 @@ pub(super) fn attach_entity_visuals(
|
||||
// The mount children's build state (decision 0441): a mounted unit's rider waits on its mount
|
||||
// child's attach — the seat is the mount's attachment-0 joint out of this `BoneAttach`; the
|
||||
// `NetEntity` is the staleness check (the field moved while the unit was still pending).
|
||||
mount_children: Query<
|
||||
(Has<VisualAttached>, Option<&super::BoneAttach>, &NetEntity),
|
||||
With<super::mount::MountBody>,
|
||||
>,
|
||||
mount_children: super::mount::MountChildren,
|
||||
// The ItemDisplayInfo catalog (armor region textures, decision 0074). Read-only here; the
|
||||
// held-item systems in the same chain hold it mutably in their own turns.
|
||||
displays: Option<Res<super::ItemDisplays>>,
|
||||
@@ -345,108 +342,47 @@ pub(super) fn attach_entity_visuals(
|
||||
if let Some(parts) = model {
|
||||
// ── Mounts (decision 0441): a mounted unit is TWO skeletons. The mount is a child
|
||||
// entity carrying a plain creature `NetEntity` — this very system builds it like any
|
||||
// beast next frame(s) — and the rider's joints then root under the mount's
|
||||
// attachment-0 seat joint. Until the mount child has attached, the unit builds
|
||||
// nothing (no naked-at-the-ground flash; the model loads dominate the wait anyway).
|
||||
// A mount child itself has no `ObjectStore`, so it can never take this branch.
|
||||
// beast next frame(s) — and the rider's rig then roots under the mount's attachment-0
|
||||
// seat joint. Until the mount child has attached, the unit builds nothing (no
|
||||
// naked-at-the-ground flash; the model loads dominate the wait anyway). A mount child
|
||||
// itself has no `ObjectStore`, so it can never take this branch.
|
||||
//
|
||||
// This is the **first build** of a unit the field already says is mounted (a
|
||||
// born-mounted NPC, a mounted player streaming in). A field change on a unit that is
|
||||
// already standing is `mount::reseat_mounts`' job, and it re-seats the standing rig
|
||||
// rather than coming back through here — the two share the seat leg below.
|
||||
let mount_display = match net.kind {
|
||||
EntityKind::Unit | EntityKind::Player => stores
|
||||
.get(entity)
|
||||
.map_or(0, |s| s.0.unit_mount_display_id()),
|
||||
_ => 0,
|
||||
};
|
||||
// Reconcile a stale mount child first: the field moved (or zeroed) while this unit
|
||||
// was still pending, or the child entity is gone — drop it and retry fresh next frame.
|
||||
if let Some(&super::mount::MountChild(child)) = mount_child {
|
||||
let built = mount_children.get(child).map(|(_, _, n)| n.display_id).ok();
|
||||
if built != Some(Some(mount_display)) {
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
ec.despawn();
|
||||
}
|
||||
commands.entity(entity).remove::<super::mount::MountChild>();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Where the rider's root bones parent: the unit entity, or the mounted seat anchor.
|
||||
let mut rider_root = entity;
|
||||
if mount_display != 0 {
|
||||
// The mount scale law (byte-verified, wow-re mount-composition B3): rendered =
|
||||
// `SCALE_X × CreatureDisplayInfo.creatureModelScale` — the CDI column ALONE (no
|
||||
// CreatureModelData.modelScale). The unit root already bakes SCALE_X, so the
|
||||
// child carries just the column; the seat anchor counter-scales it so the rider
|
||||
// keeps its own size (the client's compensating own/mount base ratio).
|
||||
let mount_scale = creatures
|
||||
.as_deref()
|
||||
.and_then(|c| c.catalog.display_scale(mount_display))
|
||||
.unwrap_or(1.0);
|
||||
match mount_child {
|
||||
None => {
|
||||
// Spawn the mount child. Its `NetEntity` registers the display want
|
||||
// (`update_display_models` scans every NetEntity), and this system
|
||||
// attaches it as a creature. Scale: the CDI column alone (the byte law
|
||||
// above); the unit root's SCALE_X composes through the hierarchy.
|
||||
let child = commands
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: Some(mount_display),
|
||||
scale: mount_scale,
|
||||
},
|
||||
super::mount::MountBody { host: entity },
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
if reattached {
|
||||
// A mount-up on a unit already in view isn't a spawn — the mount
|
||||
// joins the rebuild's fade-skip.
|
||||
commands.entity(child).insert(super::equipment::Reattached);
|
||||
}
|
||||
commands.entity(entity).add_child(child);
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(super::mount::MountChild(child));
|
||||
continue; // wait for the mount to attach
|
||||
}
|
||||
Some(&super::mount::MountChild(child)) => {
|
||||
match mount_children.get(child) {
|
||||
Ok((true, Some(bones), _)) => {
|
||||
if let Some(&(bone, offset)) = bones.points.get(&0) {
|
||||
if let Some(joint) = bones.anchor(bone) {
|
||||
// The seat anchor: a child of the mount's attachment-0
|
||||
// joint at the authored offset, counter-scaled so the
|
||||
// rider keeps its own size (byte-verified: the client's
|
||||
// body carries a compensating own/mount base ratio —
|
||||
// wow-re mount-composition B3).
|
||||
let anchor = commands
|
||||
.spawn((
|
||||
Transform::from_translation(offset).with_scale(
|
||||
Vec3::splat(1.0 / mount_scale.max(0.001)),
|
||||
),
|
||||
Visibility::default(),
|
||||
// The rider's model frame lives inside the
|
||||
// MOUNT's anchor subtree — the world pass
|
||||
// cascades a re-seat into the rider's palette
|
||||
// (decision 0724).
|
||||
crate::creature_anim::RigFrame(entity),
|
||||
))
|
||||
.id();
|
||||
commands.entity(joint).add_child(anchor);
|
||||
rider_root = anchor;
|
||||
}
|
||||
} else {
|
||||
// The reference logs exactly this and leaves the body at the
|
||||
// unit matrix (`0x60ce70`'s present-test miss).
|
||||
warn!(
|
||||
"MOUNTDISPLAYIDNOMOUNTATTACHMENT: display {mount_display} \
|
||||
authors no attachment 0 — rider stays at the unit matrix"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => continue, // the mount child is still building — wait
|
||||
}
|
||||
}
|
||||
match super::mount::seat_or_spawn_mount(
|
||||
&mut commands,
|
||||
&mount_children,
|
||||
creatures.as_deref(),
|
||||
entity,
|
||||
mount_child.map(|&super::mount::MountChild(c)| c),
|
||||
mount_display,
|
||||
reattached,
|
||||
) {
|
||||
// The mount model is still building (or was just ordered) — wait.
|
||||
super::mount::Seat::Wait => continue,
|
||||
super::mount::Seat::Frame(anchor) => rider_root = anchor,
|
||||
// The reference logs and leaves the body at the unit matrix
|
||||
// (`0x60ce70`'s present-test miss) — `seat_or_spawn_mount` wrote the warning.
|
||||
super::mount::Seat::UnitMatrix => {}
|
||||
}
|
||||
} else if let Some(&super::mount::MountChild(child)) = mount_child {
|
||||
// The field zeroed while this unit was still pending — drop the mount it ordered,
|
||||
// or the build below would stand a riderless horse under a dismounted body.
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
ec.despawn();
|
||||
}
|
||||
commands.entity(entity).remove::<super::mount::MountChild>();
|
||||
}
|
||||
// Real model: submesh children inherit the entity's (pose-driven) transform; bake scale onto it.
|
||||
let kind = match net.kind {
|
||||
|
||||
@@ -84,6 +84,7 @@ fn ammo_attach(ranged_inv_type: Option<u32>, nock_latched: bool) -> Option<u16>
|
||||
/// the glow chain's DBCs are absent — the item simply draws unadorned, as before decision 0805.
|
||||
fn resolve_glow(
|
||||
glows: Option<&mut ItemGlows>,
|
||||
enchant_rows: Option<&benilla_formats::EnchantCatalog>,
|
||||
displays: &ItemDisplays,
|
||||
display: u32,
|
||||
enchants: impl IntoIterator<Item = u32>,
|
||||
@@ -93,7 +94,7 @@ fn resolve_glow(
|
||||
return NO_GLOW;
|
||||
};
|
||||
let base = displays.catalog.get(display).map_or(0, |d| d.item_visual);
|
||||
let visual = item_glow::effective_visual(glows, base, enchants);
|
||||
let visual = item_glow::effective_visual(glows, enchant_rows, base, enchants);
|
||||
item_glow::ensure_glow_models(glows, visual, asset_server);
|
||||
visual
|
||||
}
|
||||
@@ -126,12 +127,15 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
creatures: Option<Res<Creatures>>,
|
||||
// The item/enchant glow chain (decision 0805): resolved here beside the item itself, so a glow
|
||||
// model is requested the same frame its weapon is and an enchant change rides the item diff.
|
||||
// The enchant column rides its own resource (decision 0915) — shared with the tooltip lane.
|
||||
glows: Option<ResMut<ItemGlows>>,
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
) {
|
||||
let Some(mut held) = held else {
|
||||
return;
|
||||
};
|
||||
let mut glows = glows;
|
||||
let enchant_rows = enchants.as_deref().map(|e| &e.0);
|
||||
for (
|
||||
entity,
|
||||
net_entity,
|
||||
@@ -301,6 +305,7 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
});
|
||||
let visual = resolve_glow(
|
||||
glows.as_deref_mut(),
|
||||
enchant_rows,
|
||||
&held,
|
||||
display,
|
||||
enchants.into_iter().flatten(),
|
||||
@@ -336,6 +341,7 @@ pub(in crate::entities) fn resolve_equipment(
|
||||
// hand attach does. No enchant lane here: this is a model, not an equipped item.
|
||||
let visual = resolve_glow(
|
||||
glows.as_deref_mut(),
|
||||
enchant_rows,
|
||||
&held,
|
||||
ammo.display_id,
|
||||
[],
|
||||
|
||||
@@ -971,6 +971,7 @@ mod tests {
|
||||
recursion: None,
|
||||
geometry: None,
|
||||
owner_reach: 0.0,
|
||||
water_bound: (Vec3::ZERO, 0.0),
|
||||
}];
|
||||
displays.models.insert((7, SHOULDER_KIND), dm);
|
||||
app.insert_resource(displays);
|
||||
@@ -1233,6 +1234,7 @@ mod tests {
|
||||
recursion: None,
|
||||
geometry: None,
|
||||
owner_reach: 0.0,
|
||||
water_bound: (Vec3::ZERO, 0.0),
|
||||
}];
|
||||
}
|
||||
displays.models.insert((display, kind), dm);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::{EnchantVisualCatalog, ItemVisualCatalog, ITEM_VISUAL_SLOTS};
|
||||
use benilla_formats::{EnchantCatalog, ItemVisualCatalog, ITEM_VISUAL_SLOTS};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::model_render::m2_url;
|
||||
@@ -58,23 +58,25 @@ use super::equipment::{ItemDisplays, ItemModelKind};
|
||||
use super::spell_fx::{attach_effect_visuals, EffectHost, FxTintAnims};
|
||||
use super::{DisplayModel, ModelHandle};
|
||||
|
||||
/// The glow chain's data + the effect-model cache: the two joined DBC catalogs, plus a
|
||||
/// path-keyed [`DisplayModel`] per glow model (the [`super::spell_fx::SpellFx`] pattern — entries
|
||||
/// are created by [`ensure_glow_models`] at equipment-resolve time and built by
|
||||
/// `super::update_display_models` the same frame). Optional resource: without the DBCs nothing
|
||||
/// The glow chain's data + the effect-model cache: the joined `ItemVisuals`/`ItemVisualEffects`
|
||||
/// catalog, plus a path-keyed [`DisplayModel`] per glow model (the [`super::spell_fx::SpellFx`]
|
||||
/// pattern — entries are created by [`ensure_glow_models`] at equipment-resolve time and built by
|
||||
/// `super::update_display_models` the same frame). Optional resource: without the DBC nothing
|
||||
/// glows, exactly as before this lane existed.
|
||||
///
|
||||
/// The enchant half of the fork lives in [`crate::items::Enchants`] — the same
|
||||
/// `SpellItemEnchantment` load the tooltip's enchant line reads (decision 0915). Without it a
|
||||
/// weapon still shows its INTRINSIC glow; only the enchant leg goes quiet.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct ItemGlows {
|
||||
pub(super) visuals: ItemVisualCatalog,
|
||||
pub(super) enchants: EnchantVisualCatalog,
|
||||
pub(super) models: HashMap<String, DisplayModel>,
|
||||
}
|
||||
|
||||
impl ItemGlows {
|
||||
pub(super) fn new(visuals: ItemVisualCatalog, enchants: EnchantVisualCatalog) -> Self {
|
||||
pub(super) fn new(visuals: ItemVisualCatalog) -> Self {
|
||||
ItemGlows {
|
||||
visuals,
|
||||
enchants,
|
||||
models: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -91,16 +93,19 @@ impl ItemGlows {
|
||||
/// client returns `-1` from the enchant resolver precisely so the two can't double up); otherwise
|
||||
/// the **first** enchant slot whose `SpellItemEnchantment` row carries a nonzero visual. `0` when
|
||||
/// neither does.
|
||||
/// `enchants` absent (the DBC failed to load) means only the base leg can answer.
|
||||
pub(in crate::entities) fn effective_visual(
|
||||
glows: &ItemGlows,
|
||||
enchants: Option<&EnchantCatalog>,
|
||||
base: i32,
|
||||
item_enchants: impl IntoIterator<Item = u32>,
|
||||
) -> i32 {
|
||||
if glows.visuals.effects(base).is_some() {
|
||||
return base;
|
||||
}
|
||||
let Some(enchants) = enchants else { return 0 };
|
||||
for enchant in item_enchants {
|
||||
if let Some(visual) = glows.enchants.visual(enchant) {
|
||||
if let Some(visual) = enchants.visual(enchant) {
|
||||
return visual;
|
||||
}
|
||||
}
|
||||
@@ -330,7 +335,7 @@ pub(super) fn attach_item_glows(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn catalog(visual_rows: &[u32], enchant_rows: &[(u32, i32)]) -> ItemGlows {
|
||||
fn catalog(visual_rows: &[u32], enchant_rows: &[(u32, i32)]) -> (ItemGlows, EnchantCatalog) {
|
||||
let visuals = visual_rows
|
||||
.iter()
|
||||
.map(|id| {
|
||||
@@ -342,9 +347,9 @@ mod tests {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
ItemGlows::new(
|
||||
ItemVisualCatalog::from_visuals(visuals),
|
||||
EnchantVisualCatalog::from_visuals(enchant_rows.iter().copied().collect()),
|
||||
(
|
||||
ItemGlows::new(ItemVisualCatalog::from_visuals(visuals)),
|
||||
EnchantCatalog::from_rows(enchant_rows.iter().copied().collect(), HashMap::new()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,17 +358,27 @@ mod tests {
|
||||
/// enchant slots with no visual are walked past rather than ending the scan.
|
||||
#[test]
|
||||
fn base_visual_wins_over_enchant_and_suppresses_it() {
|
||||
let glows = catalog(&[25, 61], &[(1, 61), (7, 25)]);
|
||||
let (glows, ench) = catalog(&[25, 61], &[(1, 61), (7, 25)]);
|
||||
let ench = Some(&ench);
|
||||
// Base 25 exists → base, even with an enchant that also carries one.
|
||||
assert_eq!(effective_visual(&glows, 25, [1]), 25);
|
||||
assert_eq!(effective_visual(&glows, ench, 25, [1]), 25);
|
||||
// No base → the enchant's.
|
||||
assert_eq!(effective_visual(&glows, 0, [1]), 61);
|
||||
assert_eq!(effective_visual(&glows, ench, 0, [1]), 61);
|
||||
// A visual-less enchant in the way: the scan continues to the one that has it.
|
||||
assert_eq!(effective_visual(&glows, 0, [999, 7]), 25);
|
||||
assert_eq!(effective_visual(&glows, ench, 0, [999, 7]), 25);
|
||||
// Nothing anywhere.
|
||||
assert_eq!(effective_visual(&glows, 0, [999]), 0);
|
||||
assert_eq!(effective_visual(&glows, ench, 0, [999]), 0);
|
||||
// The shipped `-1` base is not a row: it neither glows nor suppresses the enchant.
|
||||
assert_eq!(effective_visual(&glows, -1, [1]), 61);
|
||||
assert_eq!(effective_visual(&glows, -1, []), 0);
|
||||
assert_eq!(effective_visual(&glows, ench, -1, [1]), 61);
|
||||
assert_eq!(effective_visual(&glows, ench, -1, []), 0);
|
||||
}
|
||||
|
||||
/// No enchant catalog (the DBC failed to load): the INTRINSIC leg still answers in full, and
|
||||
/// only the enchant leg goes quiet — the split's whole safety claim.
|
||||
#[test]
|
||||
fn without_the_enchant_catalog_only_the_base_leg_answers() {
|
||||
let (glows, _) = catalog(&[25, 61], &[(1, 61)]);
|
||||
assert_eq!(effective_visual(&glows, None, 25, [1]), 25);
|
||||
assert_eq!(effective_visual(&glows, None, 0, [1]), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,20 @@
|
||||
//! handler instead **eases the render scale over 2 s with a cosine smoothstep** (byte-verified,
|
||||
//! `0x614bbf`). All wow-re: `questgiver-marker.md` §W6, `w2d2.md` §2.x, `object-layer.md`.
|
||||
//!
|
||||
//! Our shape is the house diff-and-rebuild ([`super::mount::refresh_mounts`]): the visual was
|
||||
//! BUILT with [`AppliedDisplay`], the
|
||||
//! live truth is the descriptor store, and a difference tears the visual down for
|
||||
//! `attach_entity_visuals` to rebuild — fade-skipped (a shapeshift isn't a spawn), waiting out the
|
||||
//! new model's async load rather than flashing a cube. The collision height restamps **in the same
|
||||
//! commit** as the swap — the 0645 rule that the collision box and the drawn body can never
|
||||
//! disagree is exactly why neither restamped alone before this.
|
||||
//! Our shape is a diff-and-rebuild: the visual was BUILT with [`AppliedDisplay`], the live truth
|
||||
//! is the descriptor store, and a difference tears the visual down for `attach_entity_visuals` to
|
||||
//! rebuild — fade-skipped (a shapeshift isn't a spawn), waiting out the new model's async load
|
||||
//! rather than flashing a cube. The collision height restamps **in the same commit** as the swap —
|
||||
//! the 0645 rule that the collision box and the drawn body can never disagree is exactly why
|
||||
//! neither restamped alone before this.
|
||||
//!
|
||||
//! A **teardown is right here and only here**: a display swap is a different model, so there is
|
||||
//! nothing to keep. The two siblings that used to share this shape no longer do — a gear change
|
||||
//! re-dresses in place (0835) and a mount transition re-seats in place (B199), because in the
|
||||
//! reference neither touches the body model at all. What survives a teardown is the unit's own
|
||||
//! per-unit state, and one piece of it is load-bearing: its [`super::spell_fx::FxAttached`] list
|
||||
//! outlives the model exactly as the reference's `+0xb4` does, so its persistent instances
|
||||
//! re-spawn onto the new body rather than being lost with the old one.
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::prelude::*;
|
||||
@@ -62,15 +69,15 @@ fn live_display_id(kind: EntityKind, store: &ObjectStore) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// Diff each attached entity's live descriptor appearance against what its visual was built with,
|
||||
/// and apply the change: a **display-id** move swaps the model (teardown → rebuild, the
|
||||
/// [`super::mount::refresh_mounts`] shape) and a **scale** move arms the 2 s ease — both restamp
|
||||
/// and apply the change: a **display-id** move swaps the model (teardown → rebuild — the one
|
||||
/// transition that still earns one) and a **scale** move arms the 2 s ease — both restamp
|
||||
/// [`CollisionHeight`] in the same commit (its two inputs are exactly these two fields; decision
|
||||
/// 0645's stamp-once rule was correct only while neither could change).
|
||||
///
|
||||
/// The self-avatar needs nothing special: it is the streamed entity (decision 0042), so the swap
|
||||
/// rebuilds its body like any other unit and `player::mirror_self_collision_height` re-syncs the
|
||||
/// swim lines from the restamp next frame. Mount children carry no [`ObjectStore`], so they can
|
||||
/// never take this path (their display is the host's field, diffed by `refresh_mounts`).
|
||||
/// never take this path (their display is the host's field, diffed by `mount::reseat_mounts`).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn refresh_live_display(
|
||||
mut commands: Commands,
|
||||
@@ -101,7 +108,7 @@ pub(super) fn refresh_live_display(
|
||||
);
|
||||
net.display_id = Some(live);
|
||||
restamp = true;
|
||||
// The refresh_mounts teardown set + our own diff key: children (parts, joints,
|
||||
// The full visual teardown set + our own diff key: children (parts, anchors,
|
||||
// held roots, mount child) despawn, the per-instance visual components strip, and
|
||||
// `attach_entity_visuals` rebuilds next frame(s) with the new display —
|
||||
// fade-skipped via `Reattached` (a shapeshift isn't a spawn).
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
//! Mounts (decision 0441): the projection from `UNIT_FIELD_MOUNTDISPLAYID` — the wire's one
|
||||
//! mounted signal — to a second creature visual under the unit. The mount is a child entity
|
||||
//! carrying a plain creature `NetEntity`, built by the ordinary attach path; the rider's joints
|
||||
//! root under the mount's attachment-0 seat joint (the `0x60ce70` present-test law); the rider's
|
||||
//! base pins to Mount(91) while the mount child locomotes through the untouched gait driver, fed
|
||||
//! the host's own movement view ([`crate::creature_anim`]'s host-view redirect). This module owns
|
||||
//! the transition: the diff-and-rebuild that mirrors the gear-swap refresh.
|
||||
//! Mounts (decisions 0441 / this session's re-seat): the projection from
|
||||
//! `UNIT_FIELD_MOUNTDISPLAYID` — the wire's one mounted signal — to a second creature visual under
|
||||
//! the unit. The mount is a child entity carrying a plain creature `NetEntity`, built by the
|
||||
//! ordinary attach path; the rider's rig re-roots under the mount's attachment-0 seat joint (the
|
||||
//! `0x60ce70` present-test law); the rider's base pins to Mount(91) while the mount child
|
||||
//! locomotes through the untouched gait driver, fed the host's own movement view
|
||||
//! ([`crate::creature_anim`]'s host-view redirect). This module owns the transition.
|
||||
//!
|
||||
//! ## The transition is a RE-SEAT, not a rebuild (B199)
|
||||
//!
|
||||
//! **VERIFIED** — wow-re `mount-composition.md` Q3/Q4, byte-cited: the change handler `0x5ffa50`
|
||||
//! runs *teardown old, then build new*, and neither half touches the rider's own model.
|
||||
//!
|
||||
//! - **Mount up, `0x607a00`:** load the mount M2 → `0x613d80 SetMountModel` into `CGUnit+0xdc` →
|
||||
//! `0x712f70 CM2Model::attachChild(this = BODY [+0xd8], parent = MOUNT, slot 0)`, which sets
|
||||
//! `body+0x1cc = mount` and links the body into the mount's child list. The **body model is
|
||||
//! re-parented, not re-created**; its world matrix is recomposed each frame from the mount's live
|
||||
//! posed attachment-0 bone (`m2_animate`'s child recursion `0x718657`–`0x71876f`).
|
||||
//! - **Dismount, `0x607ce0`:** `0x713020` **detaches** the body, `0x613d80(0)` destroys the mount
|
||||
//! model, the body goes to op4 seq 0 (Stand). Instant, no transition anim — and again the body
|
||||
//! model itself is untouched.
|
||||
//!
|
||||
//! benilla used to answer the same field change with a **full teardown** of the rider's visual
|
||||
//! (`despawn_related::<Children>()` + a strip of the rig, the player, the driver, the attach table)
|
||||
//! and let `attach_entity_visuals` rebuild it — the shape 0835 removed from gear changes for the
|
||||
//! same reason it is wrong here. Everything hanging off the rider died with it, and the one class
|
||||
//! that never came back was the **persistent aura kit instance**: `FxAttached` lives on the *unit*,
|
||||
//! survived the teardown holding a dangling root, and `attach_spell_fx`'s spawn gate is
|
||||
//! `root.is_some()` — so a mage's Ice Barrier glow was destroyed on mount-up and never re-attached,
|
||||
//! not even on dismount (the aura watcher is edge-driven off the slot set, and the aura never left
|
||||
//! it). That is B199, and 0835 named exactly this teardown as the surviving source.
|
||||
//!
|
||||
//! So the transition now moves the rig's **consumer anchors** — the only children the rig's model
|
||||
//! frame owns (decision 0724) — from the unit's own frame onto the seat anchor and back, and
|
||||
//! re-points [`RigPose::joints_root`]. Skinned parts render purely from the palette and never move;
|
||||
//! held items, spell-effect instances and overhead riders hang off the anchors and ride along, so
|
||||
//! nothing on the rider is destroyed, re-created, re-composited or re-faded by mounting.
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::creature_anim::RigPose;
|
||||
use crate::net::{NetEntity, ObjectStore};
|
||||
|
||||
/// The mount child — the second creature visual a mounted unit carries (the client's
|
||||
/// `unit+0xdc` secondary model instance). Spawned by the attach path when the field is set,
|
||||
/// despawned by [`refresh_mounts`]' rebuild (or with its parent).
|
||||
/// `unit+0xdc` secondary model instance). Spawned by [`seat_or_spawn_mount`] (from the first
|
||||
/// build or from a transition), despawned by the dismount leg or with its parent.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct MountBody {
|
||||
/// The mounted unit — the streamed entity whose field this child projects. The animation
|
||||
@@ -25,56 +56,315 @@ pub(crate) struct MountBody {
|
||||
#[derive(Component)]
|
||||
pub(crate) struct MountChild(pub(crate) Entity);
|
||||
|
||||
/// On the unit: the mount display id its current visual was BUILT with (`0` = built unmounted) —
|
||||
/// [`refresh_mounts`]' diff key, the `AppliedEquipment` pattern.
|
||||
/// On the unit: the mount display id its rig is currently **seated on** (`0` = standing on its own
|
||||
/// frame) — [`reseat_mounts`]' diff key, the `AppliedEquipment` pattern. Distinct from the live
|
||||
/// field while a transition waits on the mount model to load.
|
||||
#[derive(Component)]
|
||||
pub(super) struct AppliedMount(pub(super) u32);
|
||||
|
||||
/// Rebuild a unit's visual when its mount field changes (decision 0441): 0→id (mount up), id→0
|
||||
/// (dismount — the reference tears the secondary model down on the spot, byte-verified
|
||||
/// `0x607ce0`: detach body, `SetMountModel(0)`, body to Stand, no transition anim), id→id′ (a
|
||||
/// re-mount). The transition is a full teardown — a mount is a second SKELETON the rider's own
|
||||
/// rig re-roots under, so there is nothing to re-dress in place the way a gear change is
|
||||
/// (`attach::redress`): children — mount child, joints, parts, held roots — despawn, the visual components strip, and
|
||||
/// `attach_entity_visuals` rebuilds next frame(s) in the right configuration, fade-skipped via
|
||||
/// `Reattached` (mounting up isn't a spawn).
|
||||
pub(super) fn refresh_mounts(
|
||||
/// Spawn a unit's mount child — the client's `0x607a00` model creation. Its `NetEntity` registers
|
||||
/// the display want (`update_display_models` scans every `NetEntity`) and `attach_entity_visuals`
|
||||
/// builds it as an ordinary creature; `scale` is the **CDI `creatureModelScale` column alone**
|
||||
/// (byte law, wow-re `mount-composition.md` Q3/Q4 — the unit root's `SCALE_X` composes through the
|
||||
/// hierarchy).
|
||||
///
|
||||
/// `fade_skip` suppresses the appear-fade: a mount blinking into existence under its rider is what
|
||||
/// the teardown-era transition rendered (every rebuilt child carried `Reattached`), and it is what
|
||||
/// a mount cast should look like — the horse is *there* the instant the cast lands, not ramped in
|
||||
/// over the streaming fade meant for units coming into view.
|
||||
pub(super) fn spawn_mount_child(
|
||||
commands: &mut Commands,
|
||||
host: Entity,
|
||||
display: u32,
|
||||
scale: f32,
|
||||
fade_skip: bool,
|
||||
) -> Entity {
|
||||
let child = commands
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: Some(display),
|
||||
scale,
|
||||
},
|
||||
MountBody { host },
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
if fade_skip {
|
||||
commands.entity(child).insert(super::equipment::Reattached);
|
||||
}
|
||||
commands.entity(host).add_child(child);
|
||||
commands.entity(host).insert(MountChild(child));
|
||||
child
|
||||
}
|
||||
|
||||
/// The seat anchor — the rider's model frame while mounted: a child of the mount's attachment-0
|
||||
/// joint at the authored offset, counter-scaled so the rider keeps its own size (byte-verified:
|
||||
/// the client's body carries a compensating own/mount base ratio, `0x607b49 fld [esi+0x98]; fdiv
|
||||
/// [esi+0x9c]` → `0x710620` — wow-re `mount-composition.md` Q3).
|
||||
///
|
||||
/// [`crate::creature_anim::RigFrame`] marks it as `rider`'s model frame so the world pass cascades
|
||||
/// a re-seat into the rider's palette (decision 0724).
|
||||
fn spawn_seat_anchor(
|
||||
commands: &mut Commands,
|
||||
joint: Entity,
|
||||
offset: Vec3,
|
||||
mount_scale: f32,
|
||||
rider: Entity,
|
||||
) -> Entity {
|
||||
let anchor = commands
|
||||
.spawn((
|
||||
Transform::from_translation(offset)
|
||||
.with_scale(Vec3::splat(1.0 / mount_scale.max(0.001))),
|
||||
Visibility::default(),
|
||||
crate::creature_anim::RigFrame(rider),
|
||||
))
|
||||
.id();
|
||||
commands.entity(joint).add_child(anchor);
|
||||
anchor
|
||||
}
|
||||
|
||||
/// The seat leg shared by the first build and the transition: given a unit whose mount field says
|
||||
/// `display`, make sure the mount child exists and — once it has attached — return the frame the
|
||||
/// rider's rig should root under.
|
||||
///
|
||||
/// Three outcomes, and the caller acts on each: [`Seat::Wait`] (the mount model is still
|
||||
/// building — nothing to do this frame), [`Seat::Frame`] (seat it here) and [`Seat::UnitMatrix`]
|
||||
/// (the mount authors no attachment 0 — the reference logs and leaves the body at the unit matrix,
|
||||
/// `0x60ce70`'s present-test miss).
|
||||
pub(super) enum Seat {
|
||||
Wait,
|
||||
Frame(Entity),
|
||||
UnitMatrix,
|
||||
}
|
||||
|
||||
/// The mount children's build state a [`seat_or_spawn_mount`] caller reads: attached yet, its
|
||||
/// anchors, and the display it was actually built with (the staleness check — the field can move
|
||||
/// again while the model loads).
|
||||
pub(super) type MountChildren<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Has<super::VisualAttached>,
|
||||
Option<&'static super::BoneAttach>,
|
||||
&'static NetEntity,
|
||||
),
|
||||
With<MountBody>,
|
||||
>;
|
||||
|
||||
/// Drive one unit's mount side to the point where the rider can be seated. Spawns the child when
|
||||
/// it is missing, drops and retries a child built for a display the field has since moved off, and
|
||||
/// resolves the seat frame once the child's rig is up.
|
||||
pub(super) fn seat_or_spawn_mount(
|
||||
commands: &mut Commands,
|
||||
children: &MountChildren,
|
||||
creatures: Option<&super::Creatures>,
|
||||
unit: Entity,
|
||||
child: Option<Entity>,
|
||||
mount_display: u32,
|
||||
fade_skip: bool,
|
||||
) -> Seat {
|
||||
// The mount scale law (byte-verified, wow-re `mount-composition.md` Q3/Q4): rendered =
|
||||
// `SCALE_X × CreatureDisplayInfo.creatureModelScale` — the CDI column ALONE (no
|
||||
// CreatureModelData.modelScale).
|
||||
let mount_scale = creatures
|
||||
.and_then(|c| c.catalog.display_scale(mount_display))
|
||||
.unwrap_or(1.0);
|
||||
let Some(child) = child else {
|
||||
spawn_mount_child(commands, unit, mount_display, mount_scale, fade_skip);
|
||||
return Seat::Wait;
|
||||
};
|
||||
// A child built for a display the field has since moved off (or one that is simply gone):
|
||||
// drop it and let the next pass start over.
|
||||
let built = children.get(child).map(|(_, _, n)| n.display_id).ok();
|
||||
if built != Some(Some(mount_display)) {
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
ec.despawn();
|
||||
}
|
||||
commands.entity(unit).remove::<MountChild>();
|
||||
return Seat::Wait;
|
||||
}
|
||||
let Ok((true, Some(bones), _)) = children.get(child) else {
|
||||
return Seat::Wait; // the mount child is still building
|
||||
};
|
||||
let Some((joint, offset)) = bones
|
||||
.points
|
||||
.get(&0)
|
||||
.and_then(|&(bone, offset)| bones.anchor(bone).map(|j| (j, offset)))
|
||||
else {
|
||||
warn!(
|
||||
"MOUNTDISPLAYIDNOMOUNTATTACHMENT: display {mount_display} authors no attachment 0 — \
|
||||
rider stays at the unit matrix"
|
||||
);
|
||||
return Seat::UnitMatrix;
|
||||
};
|
||||
Seat::Frame(spawn_seat_anchor(
|
||||
commands,
|
||||
joint,
|
||||
offset,
|
||||
mount_scale,
|
||||
unit,
|
||||
))
|
||||
}
|
||||
|
||||
/// The rider's **ground** frame: its own entity, or — for a model the reference tilts to the
|
||||
/// terrain (`GlobalModelFlags & 3 ∈ {1,3}`, decisions 0482/0486) — a fresh conform node under it.
|
||||
/// The mounted rider never gets one: the tilt dispatch is on the mount-preferred model, and the
|
||||
/// composite tilts through the *mount's* node, seat joint included.
|
||||
fn ground_frame(
|
||||
commands: &mut Commands,
|
||||
unit: Entity,
|
||||
net: &NetEntity,
|
||||
creatures: Option<&super::Creatures>,
|
||||
) -> Entity {
|
||||
let tilt = net
|
||||
.display_id
|
||||
.and_then(|d| creatures?.models.get(&d))
|
||||
.map_or(0, |dm| dm.terrain_tilt);
|
||||
if tilt == 0 {
|
||||
return unit;
|
||||
}
|
||||
let node = commands
|
||||
.spawn((
|
||||
super::conform::ConformNode { unit, mode: tilt },
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
commands.entity(unit).add_child(node);
|
||||
node
|
||||
}
|
||||
|
||||
/// Move a rig's consumer anchors onto `frame` and make it the rig's model frame — the ECS twin of
|
||||
/// `0x712f70 CM2Model::attachChild` / `0x713020`'s detach. The anchors are the only children the
|
||||
/// frame owns (decision 0724: skinned parts render from the palette and hang off the unit), so
|
||||
/// this one re-parent carries every held item, spell-effect instance and overhead rider with it.
|
||||
///
|
||||
/// `pose_dirty` is raised so the same frame's compose re-seats the anchors' locals and re-writes
|
||||
/// the palette against the new root, rather than waiting for the next pose change.
|
||||
fn reseat_rig(commands: &mut Commands, rig: &mut RigPose, frame: Entity, conform: bool) {
|
||||
if rig.joints_root == frame {
|
||||
return;
|
||||
}
|
||||
let anchors: Vec<Entity> = rig.anchors.iter().map(|&(_, a)| a).collect();
|
||||
if !anchors.is_empty() {
|
||||
commands.entity(frame).add_children(&anchors);
|
||||
}
|
||||
let old = std::mem::replace(&mut rig.joints_root, frame);
|
||||
rig.pose_dirty = true;
|
||||
// A conform node the rig has just left has nothing under it any more — it belongs to the
|
||||
// frame, not to the visual, so it goes with it. (A seat anchor needs no such sweep: it dies
|
||||
// with the mount child that carries it.)
|
||||
if conform {
|
||||
if let Ok(mut ec) = commands.get_entity(old) {
|
||||
ec.despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seat a unit's rig when its mount field changes — the client's `0x5ffa50` change handler:
|
||||
/// **tear the old seat down, then build the new one**, with the rider's own visual untouched
|
||||
/// throughout (module docs).
|
||||
///
|
||||
/// The two legs are separate frames on purpose, exactly as they are separate calls there: a
|
||||
/// re-mount (id→id′) detaches onto the ground frame first, and the build leg then waits however
|
||||
/// many frames the new mount model needs to load. A rider mid-transition therefore stands on its
|
||||
/// own feet rather than vanishing — the teardown era rendered *nothing at all* for that window.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn reseat_mounts(
|
||||
mut commands: Commands,
|
||||
units: Query<
|
||||
(Entity, &NetEntity, &ObjectStore, Option<&AppliedMount>),
|
||||
mut units: Query<
|
||||
(
|
||||
Entity,
|
||||
&NetEntity,
|
||||
&ObjectStore,
|
||||
Option<&mut RigPose>,
|
||||
Option<&AppliedMount>,
|
||||
Option<&MountChild>,
|
||||
),
|
||||
With<super::VisualAttached>,
|
||||
>,
|
||||
children: MountChildren,
|
||||
conforms: Query<(), With<super::conform::ConformNode>>,
|
||||
creatures: Option<Res<super::Creatures>>,
|
||||
) {
|
||||
for (entity, net, store, applied) in &units {
|
||||
for (entity, net, store, rig, applied, child) in &mut units {
|
||||
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
let live = store.0.unit_mount_display_id();
|
||||
if live == applied.map_or(0, |a| a.0) {
|
||||
let applied = applied.map_or(0, |a| a.0);
|
||||
// Settled: seated on what the field says — and, when the field says nothing, holding no
|
||||
// mount model either. The second clause is not redundant: a mount-up whose field zeroes
|
||||
// again *before* the model finishes loading leaves an ordered-but-unseated child behind
|
||||
// (`applied` never left 0), and without it that horse would stand under a dismounted
|
||||
// rider for the rest of the unit's life.
|
||||
if live == applied && (live != 0 || child.is_none()) {
|
||||
continue;
|
||||
}
|
||||
commands
|
||||
.entity(entity)
|
||||
.despawn_related::<Children>()
|
||||
.remove::<(
|
||||
super::VisualAttached,
|
||||
super::equipment::AppliedEquipment,
|
||||
AppliedMount,
|
||||
MountChild,
|
||||
AnimationPlayer,
|
||||
bevy::animation::transition::AnimationTransitions,
|
||||
AnimationGraphHandle,
|
||||
benilla_assets::ModelAnimations,
|
||||
crate::creature_anim::AnimDriver,
|
||||
(
|
||||
crate::creature_anim::RigPose,
|
||||
crate::creature_anim::BodyTwist,
|
||||
crate::creature_anim::GlobalSeqDrive,
|
||||
),
|
||||
crate::rig_palette::RigSkin,
|
||||
super::BoneAttach,
|
||||
super::equipment::HeldAttached,
|
||||
)>()
|
||||
.insert(super::equipment::Reattached);
|
||||
// A model-less unit (the cube fallback) has no rig to seat, and the build path never
|
||||
// orders it a mount — stamp the field so it can never churn, exactly as the first build
|
||||
// does, and sweep a child if one ever did reach it (the stamp alone would then re-fire
|
||||
// every frame, since the settled test asks about the child too).
|
||||
let Some(mut rig) = rig else {
|
||||
if let Some(&MountChild(child)) = child {
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
ec.despawn();
|
||||
}
|
||||
commands.entity(entity).remove::<MountChild>();
|
||||
}
|
||||
commands.entity(entity).insert(AppliedMount(live));
|
||||
continue;
|
||||
};
|
||||
let child = child.map(|&MountChild(c)| c);
|
||||
// ── Leg 1 · `0x607ce0` — detach the body onto its own frame, destroy the mount model.
|
||||
// It runs when we are seated on a mount the field has moved off, and when the field says
|
||||
// "not mounted" at all (which covers the pending-child case above). A unit merely *waiting*
|
||||
// for its first mount model must not take it, or the build leg would tear down the child
|
||||
// it just ordered, every frame, for ever.
|
||||
if applied != 0 || live == 0 {
|
||||
// Only a body that is actually ON a seat needs detaching: a rig already standing —
|
||||
// on its own entity, or on the conform node that tilts it — is where it belongs, and
|
||||
// asking for a ground frame anyway would orphan a second conform node beside the
|
||||
// first.
|
||||
if rig.joints_root != entity && !conforms.contains(rig.joints_root) {
|
||||
let ground = ground_frame(&mut commands, entity, net, creatures.as_deref());
|
||||
reseat_rig(&mut commands, &mut rig, ground, false);
|
||||
}
|
||||
if let Some(child) = child {
|
||||
if let Ok(mut ec) = commands.get_entity(child) {
|
||||
ec.despawn();
|
||||
}
|
||||
}
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<MountChild>()
|
||||
.insert(AppliedMount(0));
|
||||
continue;
|
||||
}
|
||||
// ── Leg 2 · `0x607a00` — build the mount model and attach the body to its seat.
|
||||
match seat_or_spawn_mount(
|
||||
&mut commands,
|
||||
&children,
|
||||
creatures.as_deref(),
|
||||
entity,
|
||||
child,
|
||||
live,
|
||||
true,
|
||||
) {
|
||||
Seat::Wait => {}
|
||||
Seat::Frame(anchor) => {
|
||||
let leaving_conform = conforms.contains(rig.joints_root);
|
||||
reseat_rig(&mut commands, &mut rig, anchor, leaving_conform);
|
||||
commands.entity(entity).insert(AppliedMount(live));
|
||||
}
|
||||
Seat::UnitMatrix => {
|
||||
commands.entity(entity).insert(AppliedMount(live));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! The mount transition's law: **nothing on the rider is destroyed by mounting**.
|
||||
//!
|
||||
//! B199 (Frostshake, 08-03) is the report these pin: "mounting removes Ice Barrier's shield visual
|
||||
//! from the character — and dismounting does not bring it back". The old transition
|
||||
//! `despawn_related::<Children>()`'d the whole rider; the aura's persistent kit instance died with
|
||||
//! it and, because `FxAttached` lives on the *unit* and kept a dangling root while the aura never
|
||||
//! left its slot, nothing ever noticed. So these fixtures hang the two things that matter off a
|
||||
//! bone anchor — a held item and a spell-effect instance root — and assert they are the same
|
||||
//! entities on the far side of a mount, a dismount and a re-mount.
|
||||
|
||||
use super::*;
|
||||
use benilla_assets::{ModelJoint, ModelSkeleton};
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
use crate::entities::{BoneAttach, VisualAttached};
|
||||
use crate::net::ObjectStore;
|
||||
|
||||
/// `UNIT_FIELD_MOUNTDISPLAYID` (index 133, decision 0441) — the wire's one mounted signal.
|
||||
const FIELD_MOUNTDISPLAYID: u16 = 133;
|
||||
/// The mount's attachment-0 bone in the fixture mount rig.
|
||||
const SEAT_BONE: u16 = 3;
|
||||
|
||||
/// A rider standing on its own feet, wearing everything a mount transition used to destroy: one
|
||||
/// consumer anchor (the rig's only child), a held item under it, and a persistent spell-effect
|
||||
/// instance root under it — the Ice Barrier glow, in the shape `attach_spell_fx` builds.
|
||||
struct Standing {
|
||||
app: App,
|
||||
rider: Entity,
|
||||
/// The rig's consumer anchor — the entity every attachment hangs from.
|
||||
anchor: Entity,
|
||||
/// The held weapon root and the aura glow root, both under `anchor`.
|
||||
held: Entity,
|
||||
glow: Entity,
|
||||
}
|
||||
|
||||
/// A one-bone rig buffer rooted at `frame`, with `anchor` registered as bone 0's consumer anchor.
|
||||
fn rig_at(frame: Entity, anchor: Entity) -> RigPose {
|
||||
let skeleton = ModelSkeleton {
|
||||
joints: vec![ModelJoint {
|
||||
parent: -1,
|
||||
local_translation: Vec3::ZERO,
|
||||
billboard: None,
|
||||
ignore_parent_rotation: false,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let mut rig = RigPose::new(frame, &skeleton);
|
||||
rig.anchors.push((0, anchor));
|
||||
rig
|
||||
}
|
||||
|
||||
/// The descriptor store for a unit whose mount field reads `display`.
|
||||
fn mounted_on(display: u32) -> ObjectStore {
|
||||
ObjectStore(ObjectFields::from_pairs(&[(FIELD_MOUNTDISPLAYID, display)]))
|
||||
}
|
||||
|
||||
fn stand() -> Standing {
|
||||
let mut app = App::new();
|
||||
app.add_systems(Update, reseat_mounts);
|
||||
|
||||
let rider = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Player,
|
||||
display_id: Some(42),
|
||||
scale: 1.0,
|
||||
},
|
||||
mounted_on(0),
|
||||
VisualAttached,
|
||||
AppliedMount(0),
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
let anchor = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(rider)))
|
||||
.id();
|
||||
let held = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(anchor)))
|
||||
.id();
|
||||
let glow = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(anchor)))
|
||||
.id();
|
||||
let rig = rig_at(rider, anchor);
|
||||
app.world_mut().entity_mut(rider).insert(rig);
|
||||
app.update();
|
||||
Standing {
|
||||
app,
|
||||
rider,
|
||||
anchor,
|
||||
held,
|
||||
glow,
|
||||
}
|
||||
}
|
||||
|
||||
impl Standing {
|
||||
/// Move the wire field and run one pass.
|
||||
fn field(&mut self, display: u32) {
|
||||
self.app
|
||||
.world_mut()
|
||||
.entity_mut(self.rider)
|
||||
.insert(mounted_on(display));
|
||||
self.app.update();
|
||||
}
|
||||
|
||||
/// Finish the mount child's build the way `attach_entity_visuals` would: mark it attached and
|
||||
/// give it the attachment-0 anchor the seat hangs from. Returns the child.
|
||||
fn mount_attaches(&mut self) -> Entity {
|
||||
let child = self.mount_child().expect("a mount child was ordered");
|
||||
let joint = self
|
||||
.app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(child)))
|
||||
.id();
|
||||
self.app.world_mut().entity_mut(child).insert((
|
||||
VisualAttached,
|
||||
BoneAttach {
|
||||
anchors: [(SEAT_BONE, joint)].into_iter().collect(),
|
||||
points: [(0u16, (SEAT_BONE, Vec3::Y))].into_iter().collect(),
|
||||
markers: Default::default(),
|
||||
},
|
||||
));
|
||||
self.app.update();
|
||||
child
|
||||
}
|
||||
|
||||
fn mount_child(&self) -> Option<Entity> {
|
||||
self.app
|
||||
.world()
|
||||
.entity(self.rider)
|
||||
.get::<MountChild>()
|
||||
.map(|c| c.0)
|
||||
}
|
||||
|
||||
/// The rig's current model frame.
|
||||
fn frame(&self) -> Entity {
|
||||
self.app
|
||||
.world()
|
||||
.entity(self.rider)
|
||||
.get::<RigPose>()
|
||||
.unwrap()
|
||||
.joints_root
|
||||
}
|
||||
|
||||
fn parent_of(&self, e: Entity) -> Option<Entity> {
|
||||
self.app.world().entity(e).get::<ChildOf>().map(|c| c.0)
|
||||
}
|
||||
|
||||
fn applied(&self) -> u32 {
|
||||
self.app
|
||||
.world()
|
||||
.entity(self.rider)
|
||||
.get::<AppliedMount>()
|
||||
.map_or(0, |a| a.0)
|
||||
}
|
||||
|
||||
/// Everything that must survive every transition, by entity id.
|
||||
fn attachments_alive(&self) -> bool {
|
||||
let w = self.app.world();
|
||||
w.get_entity(self.anchor).is_ok()
|
||||
&& w.get_entity(self.held).is_ok()
|
||||
&& w.get_entity(self.glow).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// **The director's report (B199).** Mounting used to destroy the rider's whole visual, taking the
|
||||
/// aura's persistent kit instance with it — and because `FxAttached` outlived the teardown holding
|
||||
/// a dangling root while the aura never left its slots, the glow never came back, not even after
|
||||
/// dismounting. The reference re-parents the body model onto the mount (`0x712f70`); it never
|
||||
/// re-creates it. So: the same anchor, the same held item, the same glow root — now hanging under
|
||||
/// the mount's seat.
|
||||
#[test]
|
||||
fn mounting_re_seats_the_rig_and_destroys_nothing_on_the_rider() {
|
||||
let mut s = stand();
|
||||
s.field(2404);
|
||||
assert_eq!(s.applied(), 0, "still standing while the mount model loads");
|
||||
assert_eq!(s.frame(), s.rider, "…on its own frame");
|
||||
assert!(s.attachments_alive(), "…and nothing was torn down to wait");
|
||||
|
||||
s.mount_attaches();
|
||||
assert_eq!(s.applied(), 2404);
|
||||
assert!(
|
||||
s.attachments_alive(),
|
||||
"the anchor, the held item and the aura glow all survive the mount"
|
||||
);
|
||||
let seat = s.frame();
|
||||
assert_ne!(seat, s.rider, "the rig re-rooted onto a seat anchor");
|
||||
assert_eq!(
|
||||
s.parent_of(s.anchor),
|
||||
Some(seat),
|
||||
"the rig's anchor moved onto the seat",
|
||||
);
|
||||
assert_eq!(
|
||||
(s.parent_of(s.held), s.parent_of(s.glow)),
|
||||
(Some(s.anchor), Some(s.anchor)),
|
||||
"and everything hanging off it rode along, unmoved",
|
||||
);
|
||||
}
|
||||
|
||||
/// The dismount half — `0x607ce0`: detach the body back onto its own frame, destroy the mount
|
||||
/// model. The rider is again the rig's root, the mount child and its seat are gone, and the glow
|
||||
/// the report says never returns is the same entity it always was.
|
||||
#[test]
|
||||
fn dismounting_puts_the_rig_back_on_its_own_frame_and_keeps_the_glow() {
|
||||
let mut s = stand();
|
||||
s.field(2404);
|
||||
let child = s.mount_attaches();
|
||||
let seat = s.frame();
|
||||
|
||||
s.field(0);
|
||||
assert_eq!(s.applied(), 0);
|
||||
assert_eq!(s.frame(), s.rider, "the body is back at the unit matrix");
|
||||
assert_eq!(s.parent_of(s.anchor), Some(s.rider));
|
||||
assert!(
|
||||
s.mount_child().is_none(),
|
||||
"the `[unit+0xdc]` handle cleared"
|
||||
);
|
||||
assert!(
|
||||
s.app.world().get_entity(child).is_err() && s.app.world().get_entity(seat).is_err(),
|
||||
"the mount model and its seat are destroyed",
|
||||
);
|
||||
assert!(s.attachments_alive(), "the rider's own visual is untouched");
|
||||
}
|
||||
|
||||
/// A re-mount (id→id′) is `0x5ffa50`'s two calls in order: tear the old seat down first, then
|
||||
/// build the new one. The old mount dies, the new one is ordered, and the rider — which is not
|
||||
/// part of either half — never notices.
|
||||
#[test]
|
||||
fn a_re_mount_tears_the_old_seat_down_before_it_builds_the_new_one() {
|
||||
let mut s = stand();
|
||||
s.field(2404);
|
||||
let first = s.mount_attaches();
|
||||
|
||||
s.field(2405);
|
||||
assert!(
|
||||
s.app.world().get_entity(first).is_err(),
|
||||
"the old mount model is destroyed first (`0x607ce0`)",
|
||||
);
|
||||
assert_eq!(s.frame(), s.rider, "…the body detached onto its own frame");
|
||||
assert_eq!(s.applied(), 0);
|
||||
|
||||
s.app.update(); // the build leg orders the new model
|
||||
s.mount_attaches();
|
||||
assert_eq!(s.applied(), 2405);
|
||||
assert_eq!(s.parent_of(s.anchor), Some(s.frame()));
|
||||
assert!(s.attachments_alive());
|
||||
}
|
||||
|
||||
/// The field can also zero again mid-load — a cast that lands and is cancelled inside the model
|
||||
/// load. `AppliedMount` never left 0, so the diff must key on the *ordered* mount too, or the
|
||||
/// horse arrives riderless and stays for the unit's life.
|
||||
#[test]
|
||||
fn a_mount_ordered_and_then_cancelled_mid_load_is_dropped() {
|
||||
let mut s = stand();
|
||||
s.field(2404);
|
||||
let pending = s.mount_child().expect("ordered");
|
||||
s.field(0);
|
||||
assert!(
|
||||
s.app.world().get_entity(pending).is_err(),
|
||||
"the ordered-but-unseated mount is destroyed",
|
||||
);
|
||||
assert!(s.mount_child().is_none());
|
||||
assert_eq!(s.frame(), s.rider);
|
||||
s.app.update();
|
||||
assert!(s.mount_child().is_none(), "and it stays gone — no churn");
|
||||
}
|
||||
|
||||
/// The field can move again while the mount model is still loading. The pending child is dropped
|
||||
/// for one built with the display the field actually says, and the rider — which was never torn
|
||||
/// down to begin with — just keeps standing.
|
||||
#[test]
|
||||
fn a_field_that_moves_mid_load_drops_the_pending_mount() {
|
||||
let mut s = stand();
|
||||
s.field(2404);
|
||||
let pending = s.mount_child().expect("ordered");
|
||||
s.field(2405);
|
||||
assert!(
|
||||
s.app.world().get_entity(pending).is_err(),
|
||||
"the child built for the display we left is dropped",
|
||||
);
|
||||
s.app.update();
|
||||
let replacement = s.mount_child().expect("re-ordered");
|
||||
assert_eq!(
|
||||
s.app
|
||||
.world()
|
||||
.entity(replacement)
|
||||
.get::<NetEntity>()
|
||||
.unwrap()
|
||||
.display_id,
|
||||
Some(2405),
|
||||
);
|
||||
}
|
||||
|
||||
/// A mount that authors no attachment 0 is the reference's `0x60ce70` present-test miss: it logs
|
||||
/// and leaves the body at the unit matrix — mounted, but unseated. It must not spin (the field is
|
||||
/// stamped) and it must not tear the rider down either.
|
||||
#[test]
|
||||
fn a_mount_without_a_seat_leaves_the_body_at_the_unit_matrix() {
|
||||
let mut s = stand();
|
||||
s.field(2404);
|
||||
let child = s.mount_child().expect("ordered");
|
||||
s.app.world_mut().entity_mut(child).insert((
|
||||
VisualAttached,
|
||||
BoneAttach {
|
||||
anchors: Default::default(),
|
||||
points: Default::default(),
|
||||
markers: Default::default(),
|
||||
},
|
||||
));
|
||||
s.app.update();
|
||||
assert_eq!(s.applied(), 2404, "stamped — the diff cannot churn");
|
||||
assert_eq!(s.frame(), s.rider);
|
||||
assert!(s.attachments_alive());
|
||||
}
|
||||
@@ -977,8 +977,23 @@ pub(super) fn attach_spell_fx(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if inst.root.is_some() {
|
||||
return true;
|
||||
// A root that died as **collateral**: [`FxAttached`] lives on the unit and outlives
|
||||
// its model — exactly as the reference's per-CGUnit `+0xb4` effect list does — so a
|
||||
// teardown of the unit's visual (a live display swap, `live_display`; before 0835 a
|
||||
// gear change; before B199's fix a mount transition) leaves the instance holding a
|
||||
// dangling entity, and this gate's `root.is_some()` then means it is never rebuilt.
|
||||
// The aura watcher cannot notice either: it is edge-driven off the slot set, and the
|
||||
// aura never left it. 0835 named this open; here it closes. A **persistent** instance
|
||||
// re-spawns onto the rebuilt body (its aura is still live, so its visual must be);
|
||||
// a self-terminating flash or one already playing its `Decay` out simply goes.
|
||||
if let Some(root) = inst.root {
|
||||
if commands.get_entity(root).is_ok() {
|
||||
return true;
|
||||
}
|
||||
if !inst.persistent || inst.decaying {
|
||||
return false;
|
||||
}
|
||||
inst.root = None;
|
||||
}
|
||||
// Still pending: a self-terminating instance gets a generous deadline so a unit whose
|
||||
// model never materialises (the cube fallback) can't accumulate unspawnable flashes —
|
||||
@@ -1139,3 +1154,98 @@ pub(super) fn fire_fx_anim_events(
|
||||
last.retain(|e, _| live.contains(e));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::asset::AssetPlugin;
|
||||
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
|
||||
|
||||
/// One recorded instance, spawned (`root`) and awaiting nothing.
|
||||
fn instance(root: Entity, persistent: bool) -> FxInstance {
|
||||
FxInstance {
|
||||
spell_id: 13033, // Ice Barrier
|
||||
persistent,
|
||||
class: FxClass::AuraState,
|
||||
stage: FxStage::State,
|
||||
tag: 0x13,
|
||||
path: "Spells\\IceShield_State.mdx".into(),
|
||||
root: Some(root),
|
||||
expires: None,
|
||||
decaying: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An app with just what `attach_spell_fx` reads, plus one unit carrying an instance per
|
||||
/// entry of `persistent` — each with a freshly spawned root. Returns the app, the unit and the
|
||||
/// roots in order.
|
||||
fn standing(persistent: &[bool]) -> (App, Entity, Vec<Entity>) {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
|
||||
.init_asset::<WowModelMaterial>()
|
||||
.init_asset::<SkinnedMeshInverseBindposes>()
|
||||
.init_resource::<SpellFx>()
|
||||
.init_resource::<FxTintAnims>()
|
||||
.init_resource::<crate::rig_palette::RigPalettes>()
|
||||
.add_systems(Update, attach_spell_fx);
|
||||
let roots: Vec<Entity> = persistent
|
||||
.iter()
|
||||
.map(|_| app.world_mut().spawn_empty().id())
|
||||
.collect();
|
||||
let instances = roots
|
||||
.iter()
|
||||
.zip(persistent)
|
||||
.map(|(&root, &p)| instance(root, p))
|
||||
.collect();
|
||||
let unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
FxAttached { instances },
|
||||
))
|
||||
.id();
|
||||
(app, unit, roots)
|
||||
}
|
||||
|
||||
/// Each surviving instance's `(persistent, root)` after one pass.
|
||||
fn instances_of(app: &App, unit: Entity) -> Vec<(bool, Option<Entity>)> {
|
||||
app.world()
|
||||
.entity(unit)
|
||||
.get::<FxAttached>()
|
||||
.unwrap()
|
||||
.instances
|
||||
.iter()
|
||||
.map(|i| (i.persistent, i.root))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **0835's named-open item, closed.** `FxAttached` lives on the unit and outlives its model —
|
||||
/// exactly as the reference's per-CGUnit `+0xb4` effect list does — so a teardown of the unit's
|
||||
/// visual (a live display swap) leaves each instance holding a dangling root, and the spawn
|
||||
/// gate's `root.is_some()` then means it is never rebuilt. The aura watcher cannot notice: it
|
||||
/// is edge-driven off the slot set, and the aura never left it. A **persistent** instance must
|
||||
/// come back; a self-terminating flash must not.
|
||||
#[test]
|
||||
fn a_persistent_instance_whose_root_died_as_collateral_is_re_armed() {
|
||||
let (mut app, unit, roots) = standing(&[true, false]);
|
||||
// The teardown: a display swap despawns the unit's whole visual under them.
|
||||
for root in roots {
|
||||
app.world_mut().entity_mut(root).despawn();
|
||||
}
|
||||
app.update();
|
||||
assert_eq!(
|
||||
instances_of(&app, unit),
|
||||
vec![(true, None)],
|
||||
"the aura's instance is re-armed for the spawn pass; the flash is dropped",
|
||||
);
|
||||
}
|
||||
|
||||
/// A live root is left strictly alone — the gate is "is it still there?", not "re-spawn me".
|
||||
#[test]
|
||||
fn a_live_root_is_never_disturbed() {
|
||||
let (mut app, unit, roots) = standing(&[true]);
|
||||
app.update();
|
||||
assert_eq!(instances_of(&app, unit), vec![(true, Some(roots[0]))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,70 @@ pub(crate) struct HeldTemplate {
|
||||
pub(crate) material: u32,
|
||||
}
|
||||
|
||||
/// `SpellItemEnchantment.dbc`'s two consumer columns, loaded once and read by both lanes that
|
||||
/// need them: the **visual** by the weapon-glow chain (decision 0805,
|
||||
/// [`crate::entities::item_glow`]) and the **name** by the item tooltip's enchant line (decision
|
||||
/// 0915, [`crate::ui_items`]). It lives here rather than inside either consumer because it is
|
||||
/// item *data*, and because a second loader over one DBC is how a schema quietly drifts.
|
||||
///
|
||||
/// Optional, like every DBC-backed resource: absent, weapons draw unadorned and no tooltip prints
|
||||
/// an enchant line — each lane's own pre-existing behaviour.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct Enchants(pub(crate) benilla_formats::EnchantCatalog);
|
||||
|
||||
/// One enchant SLOT's contribution, as the app resolved it — `(slot index, id, charges,
|
||||
/// remaining ms)`. The id is **signed**, because its sign is load-bearing downstream: it picks the
|
||||
/// line's colour and nothing else (wow-re §E3 — `abs(id)` names the DBC row either way).
|
||||
pub(crate) type EnchantSlot = (u8, i32, u32, Option<u64>);
|
||||
|
||||
/// The tooltip lines an item instance's enchant slots contribute — the one place the app turns
|
||||
/// enchant *ids* into text (decisions 0915/0920). Every tooltip surface feeds through here, so a
|
||||
/// bag hover, a paper-doll hover and an inspect hover can never disagree.
|
||||
///
|
||||
/// The per-slot gate is the reference's, byte-verified (wow-re
|
||||
/// `ui/scratch/tooltip-content-law.md` §E3): `id != 0`, then `abs(id)` must name a real
|
||||
/// `SpellItemEnchantment` row — **the sign never changes which row**, only the colour the engine
|
||||
/// paints. An id that names no row contributes nothing rather than a placeholder.
|
||||
///
|
||||
/// `slots` is the caller's source, and it differs by surface for a reason the wire fixes: our own
|
||||
/// items stream as OBJECTS, so all 7 `ITEM_FIELD_ENCHANTMENT` slots (plus charges, plus the
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` timer) are readable; anybody else's are visible only through
|
||||
/// the slots their descriptor broadcasts, which in 1.12 vmangos fills for PERM and TEMP only.
|
||||
pub(crate) fn enchant_lines(
|
||||
slots: impl IntoIterator<Item = EnchantSlot>,
|
||||
enchants: Option<&Enchants>,
|
||||
) -> Vec<benilla_ui::script::EnchantView> {
|
||||
let Some(enchants) = enchants else {
|
||||
return Vec::new();
|
||||
};
|
||||
let lines: Vec<benilla_ui::script::EnchantView> = slots
|
||||
.into_iter()
|
||||
.filter(|&(_, id, _, _)| id != 0)
|
||||
.filter_map(|(slot, id, charges, remaining_ms)| {
|
||||
let name = enchants.0.name(id.unsigned_abs())?.to_string();
|
||||
Some(benilla_ui::script::EnchantView {
|
||||
slot,
|
||||
name,
|
||||
negative: id < 0,
|
||||
charges,
|
||||
remaining_ms,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// One breadcrumb per session the first time any surface resolves an enchant — the
|
||||
// machine-readable "this lane is live" signal for a feature whose whole symptom is ABSENCE
|
||||
// (`entities::item_glow`'s idiom, and the reason this one exists: the enchant ids reaching the
|
||||
// tooltip travel a DIFFERENT wire field from the ones the weapon glow reads, so a silent miss
|
||||
// here would look exactly like an unenchanted item).
|
||||
if !lines.is_empty() {
|
||||
static FIRST: std::sync::Once = std::sync::Once::new();
|
||||
// The whole view, not just the name: the countdown and the charges each ride a different
|
||||
// wire lane from the id, so "which of the three arrived" is exactly what this must answer.
|
||||
FIRST.call_once(|| info!("item enchant: {lines:?} (the first resolved this session)"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// The item stores: instances by guid, templates by entry (+ the in-flight ask-once set).
|
||||
/// Filled by the net bridge; read by the container APIs (`GetContainerItemInfo` and kin).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -64,6 +128,12 @@ pub(crate) struct Items {
|
||||
/// itself is what ISSUES the ask-once query, so the first resolve of a cold entry ALWAYS
|
||||
/// misses (decision 0660).
|
||||
template_epoch: u64,
|
||||
/// The **temporary-enchant deadlines**, keyed `(item guid, enchant slot)` — the reference's
|
||||
/// per-item `[obj + slot*4 + 0x324]` array (wow-re `tooltip-content-law.md` §E3), whose only
|
||||
/// feed is `SMSG_ITEM_ENCHANT_TIME_UPDATE`. NOT derived from `ITEM_FIELD_ENCHANTMENT`'s
|
||||
/// duration field, which the reference's tooltip never reads. Session state, so it clears with
|
||||
/// the objects (decision 0920).
|
||||
enchant_deadlines: HashMap<(u64, u32), std::time::Instant>,
|
||||
}
|
||||
|
||||
impl Items {
|
||||
@@ -92,6 +162,32 @@ impl Items {
|
||||
/// The item ceased to exist (`SMSG_DESTROY_OBJECT` — consumed, sold, destroyed).
|
||||
pub(crate) fn remove_object(&mut self, guid: u64) {
|
||||
self.objects.remove(&guid);
|
||||
self.enchant_deadlines.retain(|&(g, _), _| g != guid);
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` landed: park the absolute deadline for that item's enchant
|
||||
/// slot. `seconds == 0` is the reference's own "no timer" store (`0x5d9cc0`'s `<= 0` arm), so
|
||||
/// it REMOVES the entry rather than parking an already-expired one.
|
||||
pub(crate) fn set_enchant_deadline(&mut self, guid: u64, slot: u32, seconds: u32) {
|
||||
match seconds {
|
||||
0 => {
|
||||
self.enchant_deadlines.remove(&(guid, slot));
|
||||
}
|
||||
n => {
|
||||
let at = std::time::Instant::now() + std::time::Duration::from_secs(u64::from(n));
|
||||
self.enchant_deadlines.insert((guid, slot), at);
|
||||
debug!("enchant timer: item {guid:#x} slot {slot} → {n}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Milliseconds left on an item's temporary enchant, or `None` when that slot carries no timer
|
||||
/// — including an expired one (`0x5d9d00` returns 0 past the deadline, and the tooltip's
|
||||
/// `!= 0` gate then prints the plain name).
|
||||
pub(crate) fn enchant_remaining_ms(&self, guid: u64, slot: u32) -> Option<u64> {
|
||||
let at = self.enchant_deadlines.get(&(guid, slot))?;
|
||||
let left = at.saturating_duration_since(std::time::Instant::now());
|
||||
(!left.is_zero()).then_some(left.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// A tracked item object's merged descriptor fields.
|
||||
@@ -180,6 +276,7 @@ impl Items {
|
||||
/// asks (a query dropped by a dead writer must be re-askable); keep the templates (static).
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.objects.clear();
|
||||
self.enchant_deadlines.clear();
|
||||
self.pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -383,4 +480,43 @@ mod tests {
|
||||
Ok(ClientCommand::ItemQuery { entry: 118, .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// The id → row join (decisions 0915/0920): slot order is preserved, a `0` slot and an id with
|
||||
/// no `SpellItemEnchantment` name are both silently absent (never a placeholder line), and with
|
||||
/// no catalog at all nothing renders. Plus the carve's sign rule — **`abs(id)` names the row,
|
||||
/// the sign only travels** (wow-re §E3), which is why a negative id resolves at all.
|
||||
#[test]
|
||||
fn enchant_lines_join_named_ids_in_slot_order() {
|
||||
let cat = Enchants(benilla_formats::EnchantCatalog::from_rows(
|
||||
Default::default(),
|
||||
[
|
||||
(2564, "Agility +15".to_string()),
|
||||
(1900, "Crusader".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
));
|
||||
let named = |v: Vec<benilla_ui::script::EnchantView>| -> Vec<String> {
|
||||
v.into_iter().map(|e| e.name).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
named(enchant_lines(
|
||||
[(0, 2564, 0, None), (1, 0, 0, None), (4, 1900, 0, None)],
|
||||
Some(&cat)
|
||||
)),
|
||||
vec!["Agility +15".to_string(), "Crusader".to_string()]
|
||||
);
|
||||
// A NEGATIVE id resolves off `abs(id)` — the same row — and only carries its sign onward.
|
||||
let neg = enchant_lines([(0, -2564, 0, None)], Some(&cat));
|
||||
assert_eq!(neg.len(), 1);
|
||||
assert_eq!(neg[0].name, "Agility +15");
|
||||
assert!(neg[0].negative, "the sign travels to the colour rule");
|
||||
// Charges and a countdown ride the slot through untouched (the engine formats them).
|
||||
let temp = enchant_lines([(1, 1900, 5, Some(90_000))], Some(&cat));
|
||||
assert_eq!((temp[0].charges, temp[0].remaining_ms), (5, Some(90_000)));
|
||||
// An id the table doesn't name contributes nothing at all.
|
||||
assert!(enchant_lines([(0, 999_999, 0, None)], Some(&cat)).is_empty());
|
||||
// No DBC → no lines, the pre-0915 tooltip.
|
||||
assert!(enchant_lines([(0, 2564, 0, None)], None).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,10 @@ pub fn self_model_fade_alpha(dist: f32, nearclip: f32, window: f32) -> f32 {
|
||||
pub(crate) fn apply_render_fade(
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
// The water-plane axis (`model_render::classify_water_side`), composed into this writer's
|
||||
// own pick via `far_resolved` — every owner of the handle channel derives the same composed
|
||||
// handle from state, so the ramp and the classifier converge instead of re-swapping.
|
||||
far_twins: Res<crate::model_render::FarSideTwins>,
|
||||
mut q: Query<(
|
||||
Entity,
|
||||
&RenderFade,
|
||||
@@ -358,10 +362,11 @@ pub(crate) fn apply_render_fade(
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
Option<&FadeMaterials>,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
Has<crate::model_render::FarSideOfWater>,
|
||||
)>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, fade, mut tag, mut mat, anim, fm, lit) in &mut q {
|
||||
for (entity, fade, mut tag, mut mat, anim, fm, lit, far_side) in &mut q {
|
||||
let t = if fade.duration > 0.0 {
|
||||
(now - fade.started) / fade.duration
|
||||
} else {
|
||||
@@ -386,7 +391,11 @@ pub(crate) fn apply_render_fade(
|
||||
// `FadeMaterials` has no twin to swap to (it never fades in practice — every arming site
|
||||
// pairs the two); its alpha still ramps, so it can never hang invisible.
|
||||
if let Some(fm) = fm {
|
||||
let want = fm.material_for(lit, alpha < 1.0);
|
||||
let want = crate::model_render::far_resolved(
|
||||
fm.material_for(lit, alpha < 1.0),
|
||||
far_side,
|
||||
&far_twins,
|
||||
);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
|
||||
@@ -505,6 +505,244 @@ pub(crate) fn zfill_material(
|
||||
handle
|
||||
}
|
||||
|
||||
/// `clutter_fade.z` marker bit 11: this material is a **far-side-of-water twin** ([`far_twin_of`])
|
||||
/// — the water-plane interleave's mesh lane ([`crate::sky_order::FAR_SIDE_BIAS`], where the byte
|
||||
/// story lives). `WowModelExt::specialize` keys on it to keep the huge negative SORT rung out of
|
||||
/// the rasterizer `DepthBiasState` (at −4e4 that constant is a ~0.5% relative depth pull — enough
|
||||
/// to clip a blade's coplanar sheen/glow layers behind the blade's own opaque depth).
|
||||
pub(crate) const FAR_SIDE_MARKER: u16 = 1 << 11;
|
||||
|
||||
/// The far-side twin of one transparent model material: the same look, dropped one water rung —
|
||||
/// [`crate::sky_order::FAR_SIDE_BIAS`] under its authored sort slot — so the water surface paints
|
||||
/// over it, plus the [`FAR_SIDE_MARKER`] pipeline bit that keeps the rung sort-only. Pure, so the
|
||||
/// twin's shape is testable without an `Assets` store; the identity holds for every variant the
|
||||
/// swap can meet (steady blend, fade twin, zfill twin — their own marker bits ride along).
|
||||
fn far_twin_of(near: &WowModelMaterial) -> WowModelMaterial {
|
||||
let mut far = near.clone();
|
||||
far.base.depth_bias = far_sort_bias(far.base.depth_bias);
|
||||
far.extension.clutter_fade.z = far_markers(far.extension.clutter_fade.z);
|
||||
far
|
||||
}
|
||||
|
||||
/// The twin's sort slot: one water rung under wherever the source sat (a zfill twin keeps its −8
|
||||
/// under the far batches it primes; a colour batch keeps its batch eps).
|
||||
fn far_sort_bias(near: f32) -> f32 {
|
||||
near + crate::sky_order::FAR_SIDE_BIAS
|
||||
}
|
||||
|
||||
/// The twin's `clutter_fade.z` marker word: [`FAR_SIDE_MARKER`] added, every other pipeline
|
||||
/// marker preserved (a fade twin's cutout, a zfill twin's prime, the multiply blends and the fog
|
||||
/// policy all keep their identity on the far side of the plane).
|
||||
fn far_markers(z: f32) -> f32 {
|
||||
f32::from(z as u16 | FAR_SIDE_MARKER)
|
||||
}
|
||||
|
||||
/// The far twins alive right now: near material id → far twin, and the far id → near handle
|
||||
/// restore path. Both handles are strong — a twin pair lives exactly as long as some batch entity
|
||||
/// still carries its near identity (the sweep at the tail of [`classify_water_side`]), the same
|
||||
/// entity-bound lifetime `FadeMaterials`' handles get from their component.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct FarSideTwins {
|
||||
to_far: std::collections::HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
to_near: std::collections::HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
}
|
||||
|
||||
impl FarSideTwins {
|
||||
/// The far twin of `near`, if one is live — the read-only side every material-handle owner
|
||||
/// composes with, safe from a parallel walk. A miss means [`classify_water_side`] hasn't
|
||||
/// built it yet: the caller keeps the near handle and picks the twin up next frame.
|
||||
pub(crate) fn far_of(
|
||||
&self,
|
||||
near: &Handle<WowModelMaterial>,
|
||||
) -> Option<&Handle<WowModelMaterial>> {
|
||||
self.to_far.get(&near.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// The far-axis compose every material-handle OWNER applies at its own write site: the handle it
|
||||
/// wants, dropped one water rung when the entity is marked [`FarSideOfWater`]. This is what lets
|
||||
/// N legitimate writers (the fade resolve, the aura author, the self feather, the doodad-fade
|
||||
/// authority) and the classifier all converge on ONE handle per frame — each derives its pick
|
||||
/// from state and composes the same axis, so change-gates hold and nobody re-swaps what another
|
||||
/// just wrote (the first cut fought exactly that way, per frame, forever). A cutout/opaque pick
|
||||
/// composes to itself: far twins exist only for transparent-pass materials, so the lookup misses
|
||||
/// and the pick stands.
|
||||
pub(crate) fn far_resolved<'a>(
|
||||
want: &'a Handle<WowModelMaterial>,
|
||||
far: bool,
|
||||
twins: &'a FarSideTwins,
|
||||
) -> &'a Handle<WowModelMaterial> {
|
||||
if far {
|
||||
twins.far_of(want).unwrap_or(want)
|
||||
} else {
|
||||
want
|
||||
}
|
||||
}
|
||||
|
||||
/// This batch entity's transparent draw is on the eye's FAR side of the water plane — written by
|
||||
/// [`classify_water_side`], sparse (present only while far). For entities whose material handle
|
||||
/// the Visibility authority owns (the `DoodadFade` holders it pins to cutout/blend every frame,
|
||||
/// decision 0025's one-authority law), the marker IS the classification: the authority composes
|
||||
/// it into its own pick via [`FarSideTwins::far_of`] instead of a second writer fighting it —
|
||||
/// the same compose-don't-overwrite shape the fade alpha itself takes through that system.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct FarSideOfWater;
|
||||
|
||||
/// Classify every transparent M2 batch against the water plane and swap it onto (or off) its
|
||||
/// far-side twin — the MESH half of the water-plane interleave (byte-VERIFIED, wow-re
|
||||
/// `water-frame-straddle.md`: the reference splits CM2Scene transparents into above/below-water
|
||||
/// lists per model and draws the eye's far side *before* the water pass; 0911 shipped the effect
|
||||
/// half and named this one — the sighting it predicted arrived as "the sword reads crisp through
|
||||
/// the surface": the blade's Mod2x sheen and glow overlays drew after the water, untinted).
|
||||
///
|
||||
/// The grain is the reference's: every batch entity of one model instance carries the instance's
|
||||
/// own transform (the mesh offsets live in vertex space), so classifying each at its
|
||||
/// `GlobalTransform` IS the per-model split — one liquid hit at the model's placement, all its
|
||||
/// batches on one list. Straddlers keep the near-side default per batch (the reference splits
|
||||
/// those with hardware clip planes, `M2UseClipPlanes` — a deviation this system inherits from
|
||||
/// 0911 and names, not fixes). WMO translucents never classify (the reference's lists are
|
||||
/// CM2Scene's; the WMO leg draws its own), and opaque/cutout batches settle by depth like any
|
||||
/// world geometry.
|
||||
///
|
||||
/// **Two lanes, one classification.** Entities whose handle the Visibility authority owns — the
|
||||
/// `DoodadFade` holders `debug_panel::apply_model_visibility` pins to cutout/blend every frame —
|
||||
/// get the [`FarSideOfWater`] marker and a pre-built twin; the authority composes both into its
|
||||
/// own pick (decision 0025's one-authority law — the first cut swapped their handles from here
|
||||
/// and the two writers re-swapped the same 550 coastal doodads every frame, forever). Everything
|
||||
/// else (equipment, spell-fx attachments, entity fade twins) is swapped here directly, after the
|
||||
/// fade resolve, deriving near-vs-far from the CURRENT handle so an upstream overwrite is
|
||||
/// re-classified the same frame instead of fought over.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn classify_water_side(
|
||||
interleave: crate::particles::WaterInterleave,
|
||||
mut twins: ResMut<FarSideTwins>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut near_edits: MessageReader<AssetEvent<WowModelMaterial>>,
|
||||
mut commands: Commands,
|
||||
mut parts: Query<(
|
||||
Entity,
|
||||
&GlobalTransform,
|
||||
Option<&bevy::camera::visibility::RenderLayers>,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
Has<crate::model_fade::DoodadFade>,
|
||||
Has<FarSideOfWater>,
|
||||
)>,
|
||||
) {
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
// A texanim/tint ticker mutates the NEAR asset in place every drawn frame
|
||||
// (`doodad_anim::tick_anim_materials`); mirror the edit into the live twin, or a far-side
|
||||
// waterfall batch freezes its scroll the moment it classifies far. Our own twin writes touch
|
||||
// only far ids (never `to_far` keys), so this can't feed back.
|
||||
let edited: Vec<AssetId<WowModelMaterial>> = near_edits
|
||||
.read()
|
||||
.filter_map(|ev| match ev {
|
||||
AssetEvent::Modified { id } if twins.to_far.contains_key(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in edited {
|
||||
if let (Some(near), Some(far)) = (materials.get(id).cloned(), twins.to_far.get(&id)) {
|
||||
let far = far.id();
|
||||
// Can't fail: `to_far` holds the twin's strong handle, so its slot is live.
|
||||
materials
|
||||
.insert(far, far_twin_of(&near))
|
||||
.expect("far twin slot is strongly held");
|
||||
}
|
||||
}
|
||||
let mut used: std::collections::HashSet<AssetId<WowModelMaterial>> =
|
||||
std::collections::HashSet::new();
|
||||
for (entity, gt, layers, mut mat, authority_owned, marked) in &mut parts {
|
||||
// A booth-layered batch belongs to its own camera and scene — no world water applies
|
||||
// (the effect lane's own booth guard, `particles::sim`).
|
||||
if layers.is_some_and(|l| !l.intersects(&RenderLayers::default())) {
|
||||
continue;
|
||||
}
|
||||
let cur = mat.0.id();
|
||||
let (near, swapped) = match twins.to_near.get(&cur) {
|
||||
Some(n) => (n.clone(), true),
|
||||
None => (mat.0.clone(), false),
|
||||
};
|
||||
// This lane's current verdict: the marker (what every composing owner reads); the raw
|
||||
// handle state seconds it for the lanes this system swaps itself.
|
||||
let far_now = marked || swapped;
|
||||
let qualifies = match materials.get(near.id()) {
|
||||
Some(m) => {
|
||||
matches!(m.base.alpha_mode, AlphaMode::Blend) && m.extension.model_flags.x <= 0.5
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !qualifies {
|
||||
// A marked part that settled back onto its opaque cutout (fade over, aura released)
|
||||
// stops being a transparent draw at all — its side is nobody's business until it
|
||||
// feathers again.
|
||||
if marked {
|
||||
commands.entity(entity).remove::<FarSideOfWater>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
used.insert(near.id());
|
||||
let far = crate::particles::far_side_of_water(&interleave, Some(entity), gt.translation());
|
||||
if far == far_now {
|
||||
continue;
|
||||
}
|
||||
// The swap's own trace (`WOW_MOVE_TRACE_TAGS=fx`): which batches crossed the plane this
|
||||
// frame and which way — the numeric read for a sort question no pixel can answer, and
|
||||
// naturally sparse (transitions only, never per-frame spam).
|
||||
if crate::dbg_trace::enabled() {
|
||||
let p = gt.translation();
|
||||
crate::dbg_trace::line(
|
||||
"fx",
|
||||
&format!(
|
||||
"{} mesh e={entity} at=[{:.1},{:.1},{:.1}]",
|
||||
if far { "far-side" } else { "near-side" },
|
||||
p.x,
|
||||
p.y,
|
||||
p.z
|
||||
),
|
||||
);
|
||||
}
|
||||
if far {
|
||||
// Build (or fetch) the twin either way — every composing owner needs it live in the
|
||||
// map before its own pick can resolve it.
|
||||
let far_h = if let Some(h) = twins.to_far.get(&near.id()) {
|
||||
h.clone()
|
||||
} else {
|
||||
// `qualifies` above already proved the near asset exists.
|
||||
let twin = far_twin_of(materials.get(near.id()).unwrap());
|
||||
let h = materials.add(twin);
|
||||
twins.to_far.insert(near.id(), h.clone());
|
||||
twins.to_near.insert(h.id(), near.clone());
|
||||
h
|
||||
};
|
||||
commands.entity(entity).insert(FarSideOfWater);
|
||||
if !authority_owned {
|
||||
mat.0 = far_h;
|
||||
}
|
||||
} else {
|
||||
commands.entity(entity).remove::<FarSideOfWater>();
|
||||
if !authority_owned {
|
||||
mat.0 = near;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop twin pairs whose near identity no live batch carries any more — the twin dies with
|
||||
// its users. A respawned model refetches its near handle from its spawner's cache and the
|
||||
// pair rebuilds lazily on the next far classification.
|
||||
if !twins.to_far.is_empty() {
|
||||
let stale: Vec<AssetId<WowModelMaterial>> = twins
|
||||
.to_far
|
||||
.keys()
|
||||
.filter(|k| !used.contains(*k))
|
||||
.copied()
|
||||
.collect();
|
||||
for k in stale {
|
||||
if let Some(f) = twins.to_far.remove(&k) {
|
||||
twins.to_near.remove(&f.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the fog-policy bits (4-6) inside a packed `clutter_fade.z` marker word, preserving
|
||||
/// every other pipeline marker — bits 0-3 AND the Mod/Mod2x multiply markers in bits 7-8
|
||||
/// (decision 0528). The mask lives here, beside the packer above: the portrait booth's rig twin
|
||||
@@ -587,4 +825,35 @@ mod tests {
|
||||
// sub-1.0 biases all cast to the same i32 constant.
|
||||
assert_eq!(deepest as i32, 0);
|
||||
}
|
||||
|
||||
/// The far-side twin is its source shifted exactly one water rung with the marker bit added —
|
||||
/// and every OTHER marker bit rides along untouched (a fade twin's cutout, a zfill twin's
|
||||
/// prime, the multiply blends and the fog policy all keep their pipeline identity on the far
|
||||
/// side of the plane). The band stays coherent: a far zfill twin still primes before the far
|
||||
/// colour batches it serves, and the deepest batch eps still clears the water rung's margin.
|
||||
#[test]
|
||||
fn far_twin_keeps_the_source_identity_one_rung_down() {
|
||||
assert_eq!(
|
||||
super::far_sort_bias(super::ZFILL_SORT_BIAS),
|
||||
crate::sky_order::FAR_SIDE_BIAS + super::ZFILL_SORT_BIAS,
|
||||
"one rung down from wherever the source sat"
|
||||
);
|
||||
assert!(
|
||||
super::far_sort_bias(super::ZFILL_SORT_BIAS)
|
||||
< super::far_sort_bias(super::BATCH_ORDER_SORT_EPS * 512.0),
|
||||
"a far zfill twin still primes before its model's far colour batches"
|
||||
);
|
||||
let src = super::ZFILL_MARKER | super::TWIN_CUTOUT_MARKER | (3 << 4);
|
||||
let z = super::far_markers(f32::from(src)) as u16;
|
||||
assert_ne!(z & super::FAR_SIDE_MARKER, 0, "the sort-only pipeline bit");
|
||||
assert_eq!(
|
||||
z & !super::FAR_SIDE_MARKER,
|
||||
src,
|
||||
"every other marker preserved"
|
||||
);
|
||||
// The marker word stays f32-exact with every bit up to FAR_SIDE set (integers ≤ 2^12
|
||||
// are exact in f32 — the packing's soundness bound).
|
||||
let all = src | super::FAR_SIDE_MARKER | 0x0f;
|
||||
assert_eq!(f32::from(all) as u16, all);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,15 +590,15 @@ pub(crate) enum ClientCommand {
|
||||
/// server should cast (`ItemInfo::use_spell_index`) — 0 for every item whose on-use spell is
|
||||
/// its first block, which is nearly all of them.
|
||||
///
|
||||
/// `go_target` aims the use at a GameObject. That is the **key-in-a-lock** packet (decision
|
||||
/// 0769): opening a locked door with a key is "use the key at the door", not a bare cast of the
|
||||
/// key's spell — and `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot only when the cast
|
||||
/// carries `m_CastItem`, which only this packet supplies. `None` for every ordinary use.
|
||||
/// `target` is the cast-targets block, bound by the one cast ladder exactly as a
|
||||
/// [`Self::CastSpell`]'s is — the real client's `SendCast 0x6e54f0` writes one block for both
|
||||
/// opcodes. `Object` is the key-in-a-lock arm (decision 0769); `Unit` a bandage/soulstone;
|
||||
/// `SelfImplicit` every ordinary consumable.
|
||||
UseItem {
|
||||
bag_index: u8,
|
||||
slot: u8,
|
||||
spell_index: u8,
|
||||
go_target: Option<u64>,
|
||||
target: benilla_protocol::messages::UseItemTarget,
|
||||
},
|
||||
/// Open a bag item (`CMSG_OPEN_ITEM`, same bag addressing) — the drain's fork for an
|
||||
/// *openable* click (`ItemInfo::openable`): a clam, an unlocked lockbox, a wrapped gift. The
|
||||
|
||||
@@ -1039,6 +1039,13 @@ pub(super) fn apply_net_updates(
|
||||
item_guid,
|
||||
spell_id,
|
||||
} => item_cooldown(item_guid, spell_id, &items, &mut ui_actions.10),
|
||||
// The temporary-enchant countdown's ONLY feed (decision 0920): park the deadline on the
|
||||
// item store, which every tooltip surface reads back through `enchant_remaining_ms`.
|
||||
SessionEvent::ItemEnchantTime {
|
||||
item_guid,
|
||||
slot,
|
||||
seconds,
|
||||
} => items.set_enchant_deadline(item_guid, slot, seconds),
|
||||
SessionEvent::CooldownEvent { spell_id, caster } => {
|
||||
cooldown_event(spell_id, caster, &self_guid, &mut ui_actions.10)
|
||||
}
|
||||
@@ -1208,7 +1215,7 @@ pub(super) fn apply_net_updates(
|
||||
// mailbox session the feed reads (`crate::ui_mail`); the arrival pair feeds
|
||||
// `MailPending` (`HasNewMail()`/the minimap icon).
|
||||
SessionEvent::MailList { mails } => {
|
||||
mail::mail_list(mails, &mut mail_open, &net_commands, &mut mail_pending)
|
||||
mail::mail_list(mails, &mut mail_open, &net_commands)
|
||||
}
|
||||
SessionEvent::SendMailResult {
|
||||
mail_id,
|
||||
@@ -1229,8 +1236,8 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::MailItemText { text_id, text } => {
|
||||
mail::mail_item_text(text_id, text, &mut mail_open)
|
||||
}
|
||||
SessionEvent::ReceivedMail => {
|
||||
mail::received_mail(&mut mail_pending, &mail_open, &net_commands)
|
||||
SessionEvent::ReceivedMail { seconds } => {
|
||||
mail::received_mail(seconds, &mut mail_pending, &mail_open, &net_commands)
|
||||
}
|
||||
SessionEvent::NextMailTime { seconds } => {
|
||||
mail::next_mail_time(seconds, &mut mail_pending)
|
||||
|
||||
@@ -8,25 +8,21 @@ use benilla_protocol::messages::{mail_action, mail_error, MailListEntry};
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_items::EquipErrors;
|
||||
use crate::ui_mail::{mail_error_text, MailOpen, MailPending, MailSendAck, CHECKED_READ};
|
||||
use crate::ui_mail::{mail_error_text, MailOpen, MailPending, MailSendAck};
|
||||
|
||||
/// `SessionEvent::MailList` (`SMSG_MAIL_LIST_RESULT`) — replace the session's rows + fire the inbox
|
||||
/// repaint (via the feed's diff). The inbox handler auto-purges expired mail: any row whose timer ran
|
||||
/// out (`expire_days <= 0`) is deleted server-side (`CMSG_MAIL_DELETE`) and dropped here (wow-re §5,
|
||||
/// `ui/scratch/mail-interaction.md`).
|
||||
///
|
||||
/// Also clears [`MailPending`] (decision 0544 P3) when the surviving list has nothing left unread:
|
||||
/// vmangos recomputes its own `unReadMails` count whenever it serves the list, but never re-notifies
|
||||
/// the client's countdown float directly — the float is purely client-local. Mirroring "every row
|
||||
/// read (or no rows at all) ⇒ HasNewMail() goes false" is the observable law the reference itself
|
||||
/// exhibits (opening/checking the inbox clears the minimap icon); a list that still has an unread row
|
||||
/// leaves the countdown alone (a fresh unrelated mail may be independently pending).
|
||||
pub(super) fn mail_list(
|
||||
mails: Vec<MailListEntry>,
|
||||
mail: &mut MailOpen,
|
||||
commands: &NetCommands,
|
||||
pending: &mut MailPending,
|
||||
) {
|
||||
/// **It does not touch [`crate::ui_mail::MailPending`]** — and that is a positive fact, not an
|
||||
/// omission (decision 0913). This arm used to clear the countdown when the surviving list had
|
||||
/// nothing unread, on the inferred grounds that "checking your mail clears the icon" had to be the
|
||||
/// list's doing. A full write-xref of the countdown float in wow-re says otherwise: nothing on the
|
||||
/// inbox path writes it. The icon clears because **opening a letter arms the deferred-refresh flag
|
||||
/// and the mailbox *close* re-asks the server** — modelled in [`crate::ui_mail`], where the close
|
||||
/// edge lives.
|
||||
pub(super) fn mail_list(mails: Vec<MailListEntry>, mail: &mut MailOpen, commands: &NetCommands) {
|
||||
let mailbox = mail.mailbox;
|
||||
mail.mails = mails
|
||||
.into_iter()
|
||||
@@ -44,9 +40,6 @@ pub(super) fn mail_list(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if mail.mails.iter().all(|e| e.checked & CHECKED_READ != 0) {
|
||||
pending.0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `SessionEvent::SendMailResult` (`SMSG_SEND_MAIL_RESULT`) — route per action/error (decision 0544
|
||||
@@ -99,22 +92,32 @@ pub(super) fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) {
|
||||
mail.pending_bodies.remove(&text_id);
|
||||
}
|
||||
|
||||
/// `SessionEvent::ReceivedMail` (`SMSG_RECEIVED_MAIL`) — mail just arrived: the wire body carries no
|
||||
/// payload (always `u32 0`, decision 0544), so the *event itself* is the notification. Set the
|
||||
/// countdown to `0.0` (`HasNewMail()` flips true — wow-re §5 §7) and, if a mailbox session is open,
|
||||
/// re-sync its list: a server push bypasses `CheckInbox`'s 60 s client-side throttle (decision 0544
|
||||
/// P3) — the reference's own case for "the inbox visibly updates while you're standing at it".
|
||||
pub(super) fn received_mail(pending: &mut MailPending, mail: &MailOpen, commands: &NetCommands) {
|
||||
pending.0 = Some(0.0);
|
||||
/// `SessionEvent::ReceivedMail` (`SMSG_RECEIVED_MAIL`) — mail just arrived. `seconds` is the wire's
|
||||
/// delay float (vmangos always sends `0.0` = "now"); it runs the countdown's set-value ladder,
|
||||
/// which takes the **busy** branch when a mailbox window is open — arming the deferred refresh
|
||||
/// instead of moving the icon under the player's nose (wow-re `0x4ad620`, decision 0913).
|
||||
///
|
||||
/// The list re-sync is ours, not the reference's, and stays: a server push bypasses `CheckInbox`'s
|
||||
/// 60 s client-side throttle (decision 0544 P3), so a mail arriving while you stand at the mailbox
|
||||
/// shows up. The reference reaches the same place by its close-time re-query; leaving a mail you
|
||||
/// were just told about invisible for up to a minute is the worse client, and this costs one packet.
|
||||
pub(super) fn received_mail(
|
||||
seconds: f32,
|
||||
pending: &mut MailPending,
|
||||
mail: &MailOpen,
|
||||
commands: &NetCommands,
|
||||
) {
|
||||
pending.apply_received_mail(seconds, mail.mailbox.is_some());
|
||||
if let Some(mailbox) = mail.mailbox {
|
||||
let _ = commands.0.send(ClientCommand::GetMailList { mailbox });
|
||||
}
|
||||
}
|
||||
|
||||
/// `SessionEvent::NextMailTime` (`MSG_QUERY_NEXT_MAIL_TIME`'s reply, one `f32`) — seed the
|
||||
/// countdown (decision 0544 P3, wow-re §5 §7): `0.0` = mail waiting now, negative (vmangos always
|
||||
/// sends `-86400.0`) = none, a hypothetical positive value counts down per frame in `crate::ui_mail`'s
|
||||
/// `feed_mail` and flips `HasNewMail()` true at `0`.
|
||||
/// `SessionEvent::NextMailTime` (`MSG_QUERY_NEXT_MAIL_TIME`'s reply, one `f32`) — store the
|
||||
/// server's float verbatim and signal `UPDATE_PENDING_MAIL` **unconditionally** (wow-re `0x4ad5f0`,
|
||||
/// signal site `0x4ad605`; decision 0913). `0.0` = mail waiting now, negative (vmangos always sends
|
||||
/// `-86400.0`) = none, a positive value counts down per frame in `crate::ui_mail`'s `feed_mail` and
|
||||
/// flips `HasNewMail()` true as it lands inside ε.
|
||||
pub(super) fn next_mail_time(seconds: f32, pending: &mut MailPending) {
|
||||
pending.0 = Some(seconds);
|
||||
pending.apply_query_reply(seconds);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ pub(super) fn disconnected(
|
||||
mail.clear_session();
|
||||
// The arrival countdown is login-scoped (decision 0544 P3): a fresh login re-queries
|
||||
// `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing carries over across a reconnect.
|
||||
mail_pending.0 = None;
|
||||
*mail_pending = crate::ui_mail::MailPending::default();
|
||||
// An open trade dies with the socket too (decision 0592) — the reconnect starts with no trade.
|
||||
trade.clear_session();
|
||||
// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker.
|
||||
|
||||
@@ -661,8 +661,8 @@ fn writer_loop(
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
go_target,
|
||||
} => w.use_item(bag_index, slot, spell_index, go_target),
|
||||
target,
|
||||
} => w.use_item(bag_index, slot, spell_index, target),
|
||||
ClientCommand::OpenItem { bag_index, slot } => w.open_item(bag_index, slot),
|
||||
ClientCommand::AutoEquipItem { bag_index, slot } => {
|
||||
w.auto_equip_item(bag_index, slot)
|
||||
|
||||
@@ -39,8 +39,8 @@ mod sim;
|
||||
use emit::{emit_local, next_u32, rand01, rand_s11};
|
||||
use sim::simulate_particles;
|
||||
// The water-plane interleave classification — shared with the ribbon sim (a trail is one of the
|
||||
// model's emitters and classifies the same way; `sky_order::EFFECT_FAR_SIDE_BIAS`).
|
||||
pub(crate) use sim::{far_side_of_water, WaterInterleave};
|
||||
// model's emitters and classifies the same way; `sky_order::FAR_SIDE_BIAS`).
|
||||
pub(crate) use sim::{far_side_of_water, model_far_side, WaterInterleave};
|
||||
|
||||
/// Hard cap on a single emitter's live particle count — a backstop against a pathological model. Real
|
||||
/// props sit far under this (a campfire's steady state is `rate·lifespan` ≈ 30 + 24 particles).
|
||||
@@ -210,6 +210,10 @@ pub struct ParticleEmitter {
|
||||
/// (see [`owner_last_bias`]). Children carry the parent's, not their recursion model's: a
|
||||
/// child draws at the parent's anchor, so it is the parent's owner it has to clear.
|
||||
owner_reach: f32,
|
||||
/// The owner model's bound sphere (Bevy model-local centre, model-local radius) — the
|
||||
/// water-plane classification input ([`ModelEmitter::water_bound`]; the law is
|
||||
/// `sim::model_far_side`). Model-local: the live matrix supplies position and scale.
|
||||
water_bound: (Vec3, f32),
|
||||
/// The particle texture — the sim withholds pushing quads until it's resident: a
|
||||
/// still-loading or failed-to-decode texture would otherwise flash the engine fallback
|
||||
/// (white/magenta) through the additive blend. Particles still simulate meanwhile.
|
||||
@@ -592,6 +596,7 @@ pub fn spawn_emitter(
|
||||
seq,
|
||||
rng,
|
||||
owner_reach,
|
||||
water_bound: emitter.water_bound,
|
||||
texture,
|
||||
gated: false,
|
||||
recursion: emitter.recursion.clone(),
|
||||
|
||||
@@ -175,7 +175,7 @@ pub(super) struct SceneGates<'w> {
|
||||
camera_claim: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
|
||||
}
|
||||
|
||||
/// The **water-plane interleave** inputs ([`crate::sky_order::EFFECT_FAR_SIDE_BIAS`], where the
|
||||
/// The **water-plane interleave** inputs ([`crate::sky_order::FAR_SIDE_BIAS`], where the
|
||||
/// byte story lives): the loaded liquid surfaces, the eye's submersion verdict, and the room
|
||||
/// components an anchor's liquid claim resolves from. One `SystemParam` for the same 16-cap
|
||||
/// reason as [`SceneGates`].
|
||||
@@ -187,53 +187,99 @@ pub(crate) struct WaterInterleave<'w, 's> {
|
||||
parents: Query<'w, 's, &'static ChildOf>,
|
||||
}
|
||||
|
||||
/// Is this emitter's cloud on the EYE's **far side** of its local water plane?
|
||||
/// Is this draw on the EYE's **far side** of its local water plane — the point-and-slack core.
|
||||
///
|
||||
/// The reference classifies every particle emitter onto exactly ONE of the two transparent
|
||||
/// lists — above-water or below-water — by its own position against the owning model's liquid
|
||||
/// hit (`0x7084a0`), and the frame interleave draws the far-side list *before* the water pass
|
||||
/// (dry eye: below-water is far; submerged: above-water is — `0x4836d6`). This is that
|
||||
/// classification: the anchor against the nearest **admitted** surface over its XY, the claim
|
||||
/// resolved from the nearest ancestor with a room verdict (a joint owner sits under its unit;
|
||||
/// `Unknown` when nothing carries one — it admits both sources, still floor-bounded per pool).
|
||||
/// No admitted surface → near side, the reference's pass-1 default.
|
||||
pub(crate) fn far_side_of_water(
|
||||
/// The reference splits its transparents into the above-water and below-water lists and the
|
||||
/// frame interleave draws the eye's far side *before* the water pass (dry eye: below is far;
|
||||
/// submerged: above is — `0x4836d6`). Membership is `above ⇔ d ≥ −r` against the nearest
|
||||
/// **admitted** surface over the point's XY (claim from the nearest ancestor with a room
|
||||
/// verdict; `Unknown` admits both sources, still floor-bounded per pool). **No admitted surface
|
||||
/// ⇒ the above list** (`+0x19c == 0 → A = 1`, wow-re `water-frame-straddle.md` §2) — the NEAR
|
||||
/// side for a dry eye and the FAR side for a submerged one: shore content seen from under the
|
||||
/// water draws before the surface, so the surface tints it (0921's correction of 0911's
|
||||
/// "no surface → near").
|
||||
///
|
||||
/// `r` is the caller's lane law: the model bound-sphere slack for emitters and ribbons
|
||||
/// ([`model_far_side`]), `0` for the mesh lane's no-clip-planes fallback (`A = d ≥ 0`,
|
||||
/// `0x7079ed`; decision 0919).
|
||||
pub(crate) fn far_side_of_water_at(
|
||||
w: &WaterInterleave,
|
||||
claim_seed: Option<Entity>,
|
||||
anchor_world: Vec3,
|
||||
point_world: Vec3,
|
||||
r: f32,
|
||||
) -> bool {
|
||||
let wow = benilla_assets::coords::bevy_to_wow(anchor_world);
|
||||
let wow = benilla_assets::coords::bevy_to_wow(point_world);
|
||||
let mut seed = claim_seed;
|
||||
let mut room = None;
|
||||
for _ in 0..8 {
|
||||
let Some(e) = seed else { break };
|
||||
if let Ok(r) = w.rooms.get(e) {
|
||||
room = Some(r);
|
||||
if let Ok(rm) = w.rooms.get(e) {
|
||||
room = Some(rm);
|
||||
break;
|
||||
}
|
||||
seed = w.parents.get(e).ok().map(ChildOf::parent);
|
||||
}
|
||||
let claim = crate::liquid::unit_claim(room);
|
||||
let Some(d) = crate::liquid::surfaces_at(w.water.iter(), wow, claim)
|
||||
let above = match crate::liquid::surfaces_at(w.water.iter(), wow, claim)
|
||||
.map(|z| wow[2] - z)
|
||||
.min_by(|a, b| a.abs().total_cmp(&b.abs()))
|
||||
else {
|
||||
return false;
|
||||
{
|
||||
Some(d) => is_above(d, r),
|
||||
None => true,
|
||||
};
|
||||
far_side(d, w.underwater.0.any())
|
||||
if w.underwater.0.any() {
|
||||
above
|
||||
} else {
|
||||
!above
|
||||
}
|
||||
}
|
||||
|
||||
/// The side law alone, pure for the unit test: `d` = anchor − surface (WoW Z), `submerged` =
|
||||
/// the eye's state. Dry eye ⇒ the below-water list is far; submerged ⇒ the above-water one.
|
||||
/// `d = 0` sits with the above-water list, matching [`crate::player::camera`]'s waterline tie.
|
||||
fn far_side(d: f32, submerged: bool) -> bool {
|
||||
if submerged {
|
||||
d >= 0.0
|
||||
} else {
|
||||
d < 0.0
|
||||
/// The mesh lane's wrapper: the sign test at the batch's own transform (r = 0) — the
|
||||
/// reference's no-clip-planes fallback, `model_render::classify_water_side`'s law (0919).
|
||||
pub(crate) fn far_side_of_water(
|
||||
w: &WaterInterleave,
|
||||
claim_seed: Option<Entity>,
|
||||
anchor_world: Vec3,
|
||||
) -> bool {
|
||||
far_side_of_water_at(w, claim_seed, anchor_world, 0.0)
|
||||
}
|
||||
|
||||
/// The emitter/ribbon lane: **the water side is the MODEL's** (byte-VERIFIED, wow-re
|
||||
/// `water-frame-straddle.md` §6 — the 0921 correction of 0911's per-emitter gloss). The type-4
|
||||
/// walk dots the plane ONCE per model in its prologue — `world_matrix × bound-box centre`,
|
||||
/// slack `r = |matrix row 0| × header sphere radius` — and every emitter of the model reads
|
||||
/// that one cached boolean (`[ebp-8]` at `0x7085fa`); the ribbon leg reads the model's side-A
|
||||
/// verbatim (`0x7081f1`). So a shoulder flame cannot blink at the waterline: the verdict rides
|
||||
/// the model's deep, stable bound centre with a whole radius of slack, never the bobbing bone.
|
||||
///
|
||||
/// `model` = the instance-root entity — the cloud ANCHOR field ("the MODEL, never the bone"),
|
||||
/// whose `GlobalTransform` is the instance matrix; `bound` = the asset's
|
||||
/// [`benilla_assets::ModelEmitter::water_bound`]. No resolvable matrix → the sign test at
|
||||
/// `fallback_point` (the emitter's live anchor), r = 0.
|
||||
pub(crate) fn model_far_side(
|
||||
w: &WaterInterleave,
|
||||
model: Option<Entity>,
|
||||
model_gt: Option<&GlobalTransform>,
|
||||
bound: (Vec3, f32),
|
||||
fallback_point: Vec3,
|
||||
) -> bool {
|
||||
match model_gt {
|
||||
Some(gt) => {
|
||||
let point = gt.transform_point(bound.0);
|
||||
let r = gt.affine().matrix3.x_axis.length() * bound.1;
|
||||
far_side_of_water_at(w, model, point, r)
|
||||
}
|
||||
None => far_side_of_water_at(w, model, fallback_point, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The membership law alone, pure for the unit test: `above ⇔ d ≥ −r` (the reference's
|
||||
/// `fcompp; test ah,0x41; jp` at `0x7084cf` — a tie at `d == −r` lands above; NaN lands below,
|
||||
/// which `>=` gives for free). `d` = point − surface (WoW Z), `r ≥ 0` the lane's slack.
|
||||
fn is_above(d: f32, r: f32) -> bool {
|
||||
d >= -r
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -471,6 +517,7 @@ pub(super) fn simulate_particles(
|
||||
seq,
|
||||
rng,
|
||||
owner_reach,
|
||||
water_bound,
|
||||
texture,
|
||||
recursion: _,
|
||||
children,
|
||||
@@ -478,10 +525,15 @@ pub(super) fn simulate_particles(
|
||||
model_instances,
|
||||
gated: _,
|
||||
} = &mut *emitter;
|
||||
// The water-interleave claim seed, captured before the draw-anchor local shadows the
|
||||
// `anchor` field below: the owner (creature/GameObject/joint) when streamed, else the
|
||||
// cloud anchor (the MODEL) — whichever exists is where the room walk starts.
|
||||
let claim_seed = (*owner).or(*anchor);
|
||||
// The water-interleave MODEL frame, captured before the draw-anchor local shadows the
|
||||
// `anchor` field below: the cloud anchor is "the MODEL, never the bone" — its transform
|
||||
// is the instance matrix the classification dots (`model_far_side`), and the room walk
|
||||
// starts there too. The owner (possibly a JOINT) only seconds it as the walk seed when
|
||||
// no anchor exists — a joint's transform is not the instance matrix, so that leg keeps
|
||||
// the sign-test fallback (`water_gt = None`).
|
||||
let water_model = (*anchor).or(*owner);
|
||||
let water_gt = (*anchor).and_then(|e| transforms.get(e).ok().copied());
|
||||
let water_bound = *water_bound;
|
||||
*age += dt;
|
||||
// Anchored mode (see [`Particle`]): positions are emitter-relative, so tracking a moving
|
||||
// owner needs nothing beyond refreshing `placement` — the cloud rides the anchor for free
|
||||
@@ -894,11 +946,20 @@ pub(super) fn simulate_particles(
|
||||
// local water plane drops under the water pass, so the surface paints over it — the
|
||||
// swimming-enchant erase was these two sorting by raw view-z and flipping with camera
|
||||
// angle. Booth emitters belong to their own camera and take no world rung.
|
||||
// Classified at the EMITTER's own live position (`placement`), not the model anchor —
|
||||
// the reference's grain (`0x7084a0` reads the emitter): a swimmer's body sits below the
|
||||
// line while their shoulder emitter sits above it, and the model-anchor shortcut would
|
||||
// put the visible glow on the wrong list.
|
||||
let far = !is_booth && far_side_of_water(&interleave, claim_seed, placement.translation);
|
||||
// Classified at the MODEL — its transformed bound centre with the bound-radius slack —
|
||||
// never at the emitter's live position: the reference dots the plane once per model in
|
||||
// the walk prologue and every emitter reads that verdict (byte-VERIFIED, 0921 correcting
|
||||
// 0911's per-emitter reading of `0x7084a0`). This is what makes a shoulder flame stable
|
||||
// at the waterline: the bobbing bone doesn't enter, and the whole cloud only flips when
|
||||
// the model's centre is a full radius under.
|
||||
let far = !is_booth
|
||||
&& model_far_side(
|
||||
&interleave,
|
||||
water_model,
|
||||
water_gt.as_ref(),
|
||||
water_bound,
|
||||
placement.translation,
|
||||
);
|
||||
// The classification's own trace (`WOW_MOVE_TRACE_TAGS=fx`): which clouds dropped under
|
||||
// the water pass this frame — the numeric read for "the swimmer's enchant survives the
|
||||
// surface", where a pixel can't say which side a draw sorted to.
|
||||
@@ -916,7 +977,7 @@ pub(super) fn simulate_particles(
|
||||
}
|
||||
let bias = super::owner_last_bias(*owner_reach)
|
||||
+ if far {
|
||||
crate::sky_order::EFFECT_FAR_SIDE_BIAS
|
||||
crate::sky_order::FAR_SIDE_BIAS
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -1001,22 +1062,27 @@ pub(super) fn simulate_particles(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
far_side, inherit_trigger, integrate_particle, ChildEmitter, Particle, StepEnv, Vec3,
|
||||
inherit_trigger, integrate_particle, is_above, ChildEmitter, Particle, StepEnv, Vec3,
|
||||
};
|
||||
use bevy::prelude::{Quat, Transform};
|
||||
|
||||
/// The water-plane interleave's side law (`0x4836d6`): dry eye ⇒ the below-water list is the
|
||||
/// far one; submerged ⇒ the above-water list is. `d = 0` sits with the above-water list.
|
||||
/// The membership law (`0x7084cf`): `above ⇔ d ≥ −r` — the tie at `d == −r` lands ABOVE
|
||||
/// (the reference's `jp` keeps it), NaN lands BELOW (unordered → the below push), and with
|
||||
/// no slack (the mesh lane's r = 0) it degenerates to the sign test with `d = 0` above —
|
||||
/// the same tie side as [`crate::player::camera`]'s waterline. The eye then picks which
|
||||
/// list is FAR (`0x4836d6`): dry ⇒ below, submerged ⇒ above — exercised at the callers.
|
||||
#[test]
|
||||
fn far_side_follows_the_eye() {
|
||||
// Dry eye: below the surface is far (drawn before the water), above is near.
|
||||
assert!(far_side(-0.5, false));
|
||||
assert!(!far_side(0.5, false));
|
||||
assert!(!far_side(0.0, false));
|
||||
// Submerged: the sides swap.
|
||||
assert!(far_side(0.5, true));
|
||||
assert!(far_side(0.0, true));
|
||||
assert!(!far_side(-0.5, true));
|
||||
fn membership_is_d_at_least_minus_r() {
|
||||
// The slack: a model bobbing within its own radius of the plane stays ABOVE — the
|
||||
// shoulder flame's stability at the waterline (0921).
|
||||
assert!(is_above(-0.5, 2.0));
|
||||
assert!(is_above(-2.0, 2.0), "tie at d == -r lands above");
|
||||
assert!(!is_above(-2.1, 2.0));
|
||||
// r = 0 (the mesh lane): the sign test, 0 above.
|
||||
assert!(is_above(0.0, 0.0));
|
||||
assert!(!is_above(-0.1, 0.0));
|
||||
// NaN → below, the reference's unordered-compare branch — `>=` gives it for free.
|
||||
assert!(!is_above(f32::NAN, 2.0));
|
||||
}
|
||||
|
||||
fn particle(pos: Vec3, vel: Vec3) -> Particle {
|
||||
|
||||
@@ -569,7 +569,7 @@ pub(super) fn seat_camera(
|
||||
/// compose (`wow_model.wgsl`: `out_rgb *= faded_alpha`) takes the card to black, which for an ADD blend
|
||||
/// is gone. That deliberately avoids `Visibility`, which the card's own hidden-owner mirror authors every
|
||||
/// frame in a different system.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
pub(crate) fn apply_self_model_fade(
|
||||
rig: Res<CameraControl>,
|
||||
self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With<SelfPlayer>>,
|
||||
@@ -581,6 +581,7 @@ pub(crate) fn apply_self_model_fade(
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut Visibility,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
Has<crate::model_render::FarSideOfWater>,
|
||||
),
|
||||
(
|
||||
Without<RenderFade>,
|
||||
@@ -599,7 +600,11 @@ pub(crate) fn apply_self_model_fade(
|
||||
Option<&FadeMaterials>,
|
||||
Option<&mut MeshMaterial3d<WowModelMaterial>>,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
Has<crate::model_render::FarSideOfWater>,
|
||||
)>,
|
||||
// The water-plane axis, composed into every pick below (`far_resolved`) like every other
|
||||
// owner of the handle channel — the feather and the classifier converge, never re-swap.
|
||||
far_twins: Res<crate::model_render::FarSideTwins>,
|
||||
mut reauthor: ResMut<crate::interior::InteriorReauthor>,
|
||||
mut was_fading: Local<bool>,
|
||||
) {
|
||||
@@ -627,11 +632,12 @@ pub(crate) fn apply_self_model_fade(
|
||||
feather,
|
||||
&children_of,
|
||||
&mut parts,
|
||||
&far_twins,
|
||||
&mut reauthor,
|
||||
&mut walked,
|
||||
);
|
||||
let alpha = feather.clamp(0.0, 1.0);
|
||||
for (card, mut tag, anim, fm, mat, lit) in &mut cards {
|
||||
for (card, mut tag, anim, fm, mat, lit, far_side) in &mut cards {
|
||||
if !card
|
||||
.follows()
|
||||
.is_some_and(|anchor| walked.contains(&anchor))
|
||||
@@ -654,7 +660,11 @@ pub(crate) fn apply_self_model_fade(
|
||||
// (decision 0836). No `Visibility` here: that channel belongs to the card's hidden-owner
|
||||
// mirror in another system.
|
||||
if let (Some(fm), Some(mut mat)) = (fm, mat) {
|
||||
let want = fm.material_for(lit, alpha < 1.0);
|
||||
let want = crate::model_render::far_resolved(
|
||||
fm.material_for(lit, alpha < 1.0),
|
||||
far_side,
|
||||
&far_twins,
|
||||
);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
@@ -680,6 +690,7 @@ fn apply_self_fade_to_descendants(
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut Visibility,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
Has<crate::model_render::FarSideOfWater>,
|
||||
),
|
||||
(
|
||||
Without<RenderFade>,
|
||||
@@ -687,11 +698,12 @@ fn apply_self_fade_to_descendants(
|
||||
Without<crate::billboard::BillboardCard>,
|
||||
),
|
||||
>,
|
||||
far_twins: &crate::model_render::FarSideTwins,
|
||||
reauthor: &mut crate::interior::InteriorReauthor,
|
||||
walked: &mut EntityHashSet,
|
||||
) {
|
||||
walked.insert(entity);
|
||||
if let Ok((fm, mut tag, mut mat, mut vis, lit)) = parts.get_mut(entity) {
|
||||
if let Ok((fm, mut tag, mut mat, mut vis, lit, far_side)) = parts.get_mut(entity) {
|
||||
if alpha >= 1.0 {
|
||||
// The release edge (runs once, on the frame the fade ends — decision 0213): un-hide,
|
||||
// restore the alpha field this system owns, and hand the material back to the part's
|
||||
@@ -706,7 +718,8 @@ fn apply_self_fade_to_descendants(
|
||||
if tag.0 != bits {
|
||||
tag.0 = bits;
|
||||
}
|
||||
let want = fm.material_for(lit, false);
|
||||
let want =
|
||||
crate::model_render::far_resolved(fm.material_for(lit, false), far_side, far_twins);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
@@ -736,7 +749,8 @@ fn apply_self_fade_to_descendants(
|
||||
// exterior twin at shade byte 0 read as full outdoor intensity deep indoors
|
||||
// (director-caught, 2026-07-13). Shared with the appear/despawn ramp since 0755, so
|
||||
// the two can never disagree about which twin a law wants.
|
||||
let want = fm.material_for(lit, true);
|
||||
let want =
|
||||
crate::model_render::far_resolved(fm.material_for(lit, true), far_side, far_twins);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
@@ -744,7 +758,15 @@ fn apply_self_fade_to_descendants(
|
||||
}
|
||||
if let Ok(children) = children_of.get(entity) {
|
||||
for &child in children {
|
||||
apply_self_fade_to_descendants(child, alpha, children_of, parts, reauthor, walked);
|
||||
apply_self_fade_to_descendants(
|
||||
child,
|
||||
alpha,
|
||||
children_of,
|
||||
parts,
|
||||
far_twins,
|
||||
reauthor,
|
||||
walked,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,6 +900,8 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<CameraControl>();
|
||||
app.init_resource::<crate::interior::InteriorReauthor>();
|
||||
// The water-plane twin map the feather composes with (empty — no water in a fixture).
|
||||
app.init_resource::<crate::model_render::FarSideTwins>();
|
||||
app.add_systems(Update, apply_self_model_fade);
|
||||
|
||||
// The avatar: root -> joint (the eye-glow bone). Its card follows the joint.
|
||||
|
||||
@@ -1440,6 +1440,7 @@ mod tests {
|
||||
recursion: None,
|
||||
geometry: None,
|
||||
owner_reach: 0.0,
|
||||
water_bound: (Vec3::ZERO, 0.0),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ pub struct RibbonTrail {
|
||||
/// SAME rung as the quad clouds beside it (0721). Was the material's `depth_bias`; now the
|
||||
/// draw record's sort-key add.
|
||||
bias: f32,
|
||||
/// The owner model's bound sphere ([`ModelRibbon::water_bound`]) — the water-plane side is
|
||||
/// the MODEL's (0921): the ribbon leg reads the model's side-A boolean verbatim, slack
|
||||
/// included, so the trail flips with its model's bound centre, never with its whipping head.
|
||||
water_bound: (Vec3, f32),
|
||||
}
|
||||
|
||||
impl RibbonTrail {
|
||||
@@ -172,6 +176,7 @@ pub fn spawn_ribbon(
|
||||
// the same rung the quad clouds take, from the same authored reach,
|
||||
// because a trail is one of the model's emitters.
|
||||
bias: crate::particles::owner_last_bias(ribbon.owner_reach * owner_scale),
|
||||
water_bound: ribbon.water_bound,
|
||||
},
|
||||
))
|
||||
.id(),
|
||||
@@ -221,6 +226,7 @@ pub(crate) fn simulate_ribbons(
|
||||
age,
|
||||
texture,
|
||||
bias,
|
||||
water_bound,
|
||||
} = &mut *trail;
|
||||
// The keyed look tracks sample on the trail's clip clock (see [`RibbonTrail::age`]):
|
||||
// heights at edge-commit time (each edge keeps the width it was born with — the
|
||||
@@ -369,11 +375,21 @@ pub(crate) fn simulate_ribbons(
|
||||
// particle "unfogged" file flag — pass 0.)
|
||||
fog: EffectFog::for_blend(0, def.blend),
|
||||
anchor,
|
||||
// The owner rung, dropped under the water pass when the head node sits on the
|
||||
// eye's far side of its water plane — the same interleave the quad clouds take.
|
||||
// The owner rung, dropped under the water pass when the MODEL sits on the eye's
|
||||
// far side of its water plane — the model's bound centre with the bound-radius
|
||||
// slack, never the whipping head node (0921: the ribbon leg reads the model's
|
||||
// side-A boolean verbatim, `0x7081f1`). The model frame is `alpha_src` — "the
|
||||
// MODEL INSTANCE" — with the owner (possibly a joint) seconding as the walk
|
||||
// seed; an unresolvable matrix falls back to the sign test at the head.
|
||||
bias: *bias
|
||||
+ if crate::particles::far_side_of_water(&interleave, *owner, anchor) {
|
||||
crate::sky_order::EFFECT_FAR_SIDE_BIAS
|
||||
+ if crate::particles::model_far_side(
|
||||
&interleave,
|
||||
alpha_src.or(*owner),
|
||||
alpha_src.and_then(|e| transforms.get(e).ok()),
|
||||
*water_bound,
|
||||
anchor,
|
||||
) {
|
||||
crate::sky_order::FAR_SIDE_BIAS
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
|
||||
@@ -86,15 +86,19 @@ pub(crate) const WHITE_MOON_BIAS: f32 = -8.1e5;
|
||||
pub(crate) const MOON02_BIAS: f32 = -8.0e5;
|
||||
/// The cloud dome — last of the sky pass (`0x6d4a71`): clouds blend over a setting sun.
|
||||
pub(crate) const CLOUDS_BIAS: f32 = -6.0e5;
|
||||
/// **Far-side model effects** — the water-plane interleave's early half (byte-VERIFIED, wow-re
|
||||
/// `water-frame-straddle.md`): the reference splits M2 transparents into an above-water and a
|
||||
/// below-water list per model (per **emitter** for particles — each classifies onto exactly ONE
|
||||
/// list, `0x7084a0`), and `0x483460` draws the list on the eye's FAR side of the water plane
|
||||
/// *before* the water pass, the near side *after*. An effect classified far-side takes this rung
|
||||
/// (plus its owner-last rung, capped at 32) so it lands under [`WATER_BIAS`]; near-side effects
|
||||
/// **Far-side model transparents** — the water-plane interleave's early half (byte-VERIFIED,
|
||||
/// wow-re `water-frame-straddle.md`): the reference splits M2 transparents into an above-water
|
||||
/// and a below-water list per model (per **emitter** for particles — each classifies onto exactly
|
||||
/// ONE list, `0x7084a0`), and `0x483460` draws the list on the eye's FAR side of the water plane
|
||||
/// *before* the water pass, the near side *after*. A draw classified far-side takes this rung —
|
||||
/// an effect plus its owner-last rung (capped at 32), a translucent M2 mesh batch plus its batch
|
||||
/// eps (≤ ~65 × 1e-3, `model_render::BATCH_ORDER_SORT_EPS`), a zfill twin minus its 8 — so it
|
||||
/// lands under [`WATER_BIAS`] with the owner-last law intact inside the band; near-side draws
|
||||
/// keep their natural slot above it. The flip on submersion is the interleave inversion
|
||||
/// (`0x4836d6 cmp eax,0xf`). Sort-only — the effect lane splits sort bias from raster bias.
|
||||
pub(crate) const EFFECT_FAR_SIDE_BIAS: f32 = -4.0e4;
|
||||
/// (`0x4836d6 cmp eax,0xf`). Sort-only on every lane: the effect stream splits sort from raster
|
||||
/// by construction, and the mesh lane's far twin zeroes the raster constant back in
|
||||
/// `WowModelExt::specialize` (`clutter_fade.z` bit 11).
|
||||
pub(crate) const FAR_SIDE_BIAS: f32 = -4.0e4;
|
||||
/// **The water surface** — river/ocean/WMO water draw in a fixed frame slot between the two
|
||||
/// transparent halves (`0x6701d0 → 0x6816d0`: ocean → river → WMO liquid → foam), never view-z
|
||||
/// sorted against model transparents. One rung below the world band puts every unclassified
|
||||
@@ -120,11 +124,12 @@ const _: () = {
|
||||
assert!(SUN_DISC_BIAS - STARS_BIAS > 1.0e4);
|
||||
assert!(WHITE_MOON_BIAS > SUN_DISC_BIAS && MOON02_BIAS > WHITE_MOON_BIAS);
|
||||
assert!(CLOUDS_BIAS - MOON02_BIAS > 1.0e4);
|
||||
// The water-plane interleave: sky < far-side effects < water < world transparents (the
|
||||
// near-side default). The far-side band is far-effect-bias + owner rung; owner rungs are
|
||||
// capped well under the 1e4 margin (benilla_formats::owner_last_rung's ceiling).
|
||||
assert!(EFFECT_FAR_SIDE_BIAS - CLOUDS_BIAS > 1.0e4);
|
||||
assert!(WATER_BIAS - EFFECT_FAR_SIDE_BIAS > 1.0e4);
|
||||
// The water-plane interleave: sky < far-side transparents < water < world transparents (the
|
||||
// near-side default). The far-side band is FAR_SIDE_BIAS − 8 (a zfill twin) … + owner rung;
|
||||
// owner rungs are capped well under the 1e4 margin (benilla_formats::owner_last_rung's
|
||||
// ceiling) and the mesh batch eps under that.
|
||||
assert!(FAR_SIDE_BIAS - CLOUDS_BIAS > 1.0e4);
|
||||
assert!(WATER_BIAS - FAR_SIDE_BIAS > 1.0e4);
|
||||
assert!(-3.0e3 - WATER_BIAS > 1.0e4); // world view-z floor = −far (the ~3 km projection) stays above
|
||||
assert!(crate::ground_fx::GROUND_FX_DEPTH_BIAS - CLOUDS_BIAS > 1.0e4);
|
||||
assert!(GLARE_BIAS - crate::ground_fx::GROUND_FX_DEPTH_BIAS > 1.0e4);
|
||||
|
||||
@@ -267,11 +267,18 @@ pub(super) fn act_on_right_click(
|
||||
debug!(
|
||||
"right-click gameobject open-by-key: use item ({bag_index},{slot}) blk {spell_index} at {guid:#x}"
|
||||
);
|
||||
// NOT through [`crate::ui_action::CastLadder`] yet — this arm and the
|
||||
// `OpenLock` cast above are the last two sends outside the one ladder, and
|
||||
// folding them in is its own slice: the system is already at the
|
||||
// SystemParam ceiling, the resolver would have to carry the key's ON_USE
|
||||
// *spell* as well as its block ordinal, and the binder has no GameObject
|
||||
// arm (`CastCommit::Item::on_object` is the seam that awaits it). Stated
|
||||
// gap, decision 0914.
|
||||
let _ = net.0.send(ClientCommand::UseItem {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
go_target: Some(guid),
|
||||
target: benilla_protocol::messages::UseItemTarget::Object(guid),
|
||||
});
|
||||
}
|
||||
GoAction::Refuse(err) => {
|
||||
|
||||
@@ -63,6 +63,12 @@ pub struct WowModelKey {
|
||||
/// `M2UseZFill` clone command, wow-re `m2-blend-promotion-zfill.md` §4): `specialize` masks the
|
||||
/// colour writes off, turns blend off, and forces depth-write ON.
|
||||
zfill: bool,
|
||||
/// Far side of the water plane (`clutter_fade.z` bit 11 — `model_render`'s far twin, the
|
||||
/// water-plane interleave's mesh lane): the material's huge negative SORT bias must stay
|
||||
/// sort-only, so `specialize` zeroes the rasterizer `DepthBiasState` constant the base
|
||||
/// `StandardMaterial` derived from the same field (at −4e4 that constant is a ~0.5% relative
|
||||
/// depth pull — enough to clip a blade's coplanar sheen/glow layers behind the blade itself).
|
||||
far_side: bool,
|
||||
// NB: the WMO authored batch order is deliberately NOT a key axis. It used to be (a
|
||||
// per-batch-index `DepthBiasState` constant), which made every batch index its own pipeline —
|
||||
// the city first-sight compile stall (decision 0837). The coplanar-layering nudge now rides
|
||||
@@ -82,6 +88,7 @@ impl From<&WowModelExt> for WowModelKey {
|
||||
modulate: markers & 0x80 != 0,
|
||||
modulate2x: markers & 0x100 != 0,
|
||||
zfill: markers & 0x200 != 0,
|
||||
far_side: markers & 0x800 != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +217,18 @@ impl MaterialExtension for WowModelExt {
|
||||
ds.depth_compare = CompareFunction::Always;
|
||||
}
|
||||
}
|
||||
// A far-side-of-water twin's bias is a SORT rung only (the water-plane interleave,
|
||||
// `sky_order::FAR_SIDE_BIAS`): the base `StandardMaterial::specialize` has just packed the
|
||||
// same f32 into the rasterizer `DepthBiasState` constant (bevy 0.18 `pbr_material.rs`,
|
||||
// `depth_stencil.bias.constant`), where −4e4 ULPs would pull every far fragment ~0.5%
|
||||
// deeper and clip a blade's coplanar sheen/glow against the blade's own opaque depth.
|
||||
// Zero it back — the effect lane splits sort from raster by construction; this bit is the
|
||||
// mesh lane's split.
|
||||
if key.bind_group_data.far_side {
|
||||
if let Some(ds) = descriptor.depth_stencil.as_mut() {
|
||||
ds.bias.constant = 0;
|
||||
}
|
||||
}
|
||||
// The WMO authored-batch-order depth nudge (the coplanar MOBA layering determinism) is NOT
|
||||
// here any more: as a fixed-function `DepthBiasState` constant it made every batch index its
|
||||
// own PIPELINE — Stormwind alone queued ~3000 variants of this one shader, each a
|
||||
|
||||
@@ -12,9 +12,23 @@
|
||||
//!
|
||||
//! A refusal here is **local and pre-commit**, exactly like the reference's: no packet, no GCD, no
|
||||
//! pending arm, no autorepeat key — just the red error line's reason code.
|
||||
//!
|
||||
//! **An item use is a cast, and takes this same ladder** (decision 0914). `CGItem::Use 0x5d8d00`'s
|
||||
//! ordinary tail (`5d9249`–`5d9258`) calls `0x6e5a90`, whose whole body is `call 0x6e4b60` —
|
||||
//! `TryCast`, with the **item as an ordinary third argument** (`ret 0xc`: item, targetLo,
|
||||
//! targetHi). Inside TryCast that argument is read exactly twice — at `6e4d76`, where it only
|
||||
//! computes a display flag and does *not* skip the IsCasting gate below it, and at `6e4f33`, where
|
||||
//! it is forwarded to the requirement validator `0x6094f0`. Every rung between entry and the
|
||||
//! commit is therefore the same code for a spell and for an item, which is why [`CastCommit`] is a
|
||||
//! *parameter* of this function and not a second path. Two rungs fork on it, and only two: the
|
||||
//! validator's first rung (`60952b`: item → the item cooldown query `0x6e2ed0` and error **0x28**
|
||||
//! "Item is not ready yet."; no item → `0x6e2ea0` and **0x3c** "Spell is not ready yet.") and the
|
||||
//! commit's opcode (`0x6e57d8 push 0xab` vs `push 0x12e`).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use benilla_protocol::messages::UseItemTarget;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::items::Items;
|
||||
@@ -22,6 +36,100 @@ use crate::net::{ClientCommand, NetCommands, SelfPlayer};
|
||||
|
||||
use super::{cast_target, reagent_totem_refusal, state, AutoRepeatActive, CastErrors, Spells};
|
||||
|
||||
/// **What the commit writes** — `SendCast 0x6e54f0`'s one branch on item-present. The sender
|
||||
/// discriminates on whether the pending-cast block's guid (`0xceac48`, filled at `6e4f8d`–`6e4fa6`
|
||||
/// from the ITEM's guid when TryCast was handed one, else the caster's) is the caster's, and the
|
||||
/// item arm falls through to `0x6e57d8 push 0xab` = `CMSG_USE_ITEM` (wow-re `action-item-slot.md`
|
||||
/// §8, `cursor-system.md` §8.4a — both byte-read).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CastCommit {
|
||||
/// `CMSG_CAST_SPELL 0x12e` — no item bound.
|
||||
Spell,
|
||||
/// `CMSG_USE_ITEM 0xab` — the item's wire position and template spell ordinal (decision 0666),
|
||||
/// plus the GameObject the caller bound explicitly, if any.
|
||||
Item {
|
||||
bag_index: u8,
|
||||
slot: u8,
|
||||
spell_index: u8,
|
||||
/// The key-in-a-lock arm (decision 0769) — `CGItem::Use`'s own target argument, non-zero
|
||||
/// only for the lock chain. A caller-bound guid short-circuits the binder below, exactly
|
||||
/// as TryCast's target resolve (`6e4ef4`) takes the pair it was passed.
|
||||
on_object: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CastCommit {
|
||||
/// Whether this commit carries an item — the discriminator every forked rung reads.
|
||||
pub(crate) fn is_item(self) -> bool {
|
||||
matches!(self, CastCommit::Item { .. })
|
||||
}
|
||||
|
||||
/// The local not-ready reason for this commit (`0x6094f0`'s forked first rung).
|
||||
fn not_ready_reason(self) -> u8 {
|
||||
if self.is_item() {
|
||||
0x28
|
||||
} else {
|
||||
0x3c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The one cast-send path's whole input set, as ONE [`SystemParam`]** — so a caller needs two
|
||||
/// params (this and [`cast_target::CastTargeting`]) instead of fifteen, so a new rung's input
|
||||
/// lands in every caller at once, and so [`CastLadder::send`] is structurally the only way into
|
||||
/// [`send_spell_cast`] (the root-cause rule: never duplicate a send path — make it impossible).
|
||||
///
|
||||
/// Every caster surface takes it: the action bar's two arms, the spellbook, the stance bar, the
|
||||
/// trade-skill and craft windows, the bag/paper-doll item click, and the ground-targeting commit.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct CastLadder<'w, 's> {
|
||||
pub(crate) commands: Res<'w, NetCommands>,
|
||||
pub(crate) self_player:
|
||||
Query<'w, 's, (Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
pub(crate) spells: Option<Res<'w, Spells>>,
|
||||
/// The item cache — the pre-send totem/reagent check reads it (decision 0552), and the item
|
||||
/// arms resolve templates through it.
|
||||
pub(crate) items: ResMut<'w, Items>,
|
||||
pub(crate) sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
|
||||
pub(crate) ecs: Commands<'w, 's>,
|
||||
pub(crate) pending: ResMut<'w, crate::ui_cast::PendingCast>,
|
||||
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
|
||||
pub(crate) cooldowns: ResMut<'w, crate::cooldowns::Cooldowns>,
|
||||
pub(crate) cast_errors: ResMut<'w, CastErrors>,
|
||||
pub(crate) auto_repeat: ResMut<'w, AutoRepeatActive>,
|
||||
pub(crate) trade_skill_opens: ResMut<'w, crate::ui_tradeskill::TradeSkillOpens>,
|
||||
pub(crate) ground: ResMut<'w, super::targeting::SpellTargeting>,
|
||||
}
|
||||
|
||||
impl CastLadder<'_, '_> {
|
||||
/// Run the ladder for `spell_id` and commit as `commit` says — the only entry point.
|
||||
pub(crate) fn send(
|
||||
&mut self,
|
||||
spell_id: u32,
|
||||
ctx: &cast_target::CastContext,
|
||||
commit: CastCommit,
|
||||
) {
|
||||
send_spell_cast(
|
||||
spell_id,
|
||||
ctx,
|
||||
commit,
|
||||
&self.commands,
|
||||
&self.self_player,
|
||||
self.spells.as_deref(),
|
||||
&self.items,
|
||||
&mut self.sheath,
|
||||
&mut self.ecs,
|
||||
&mut self.pending,
|
||||
&mut self.queued_melee,
|
||||
&mut self.cooldowns,
|
||||
&mut self.cast_errors,
|
||||
&mut self.auto_repeat,
|
||||
&mut self.trade_skill_opens,
|
||||
&mut self.ground,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one spell cast at the current selection — the client's local cast-send follow-through
|
||||
/// (the client's `0x6e54f0` tail): a ranged-attribute spell arms the **ranged stance** now
|
||||
/// (`0x6e5930`'s `SetSheatheState(2,1,1)` — the echo START re-requests it, idempotent), an
|
||||
@@ -38,9 +146,10 @@ use super::{cast_target, reagent_totem_refusal, state, AutoRepeatActive, CastErr
|
||||
/// `CMSG_CAST_SPELL` the server bounces back as a spurious cast-bar cancel. Ranged/auto-repeat
|
||||
/// shots keep their own lifecycle — they never arm the guard and are never blocked by it.
|
||||
#[allow(clippy::too_many_arguments)] // every input the follow-through + the send itself need
|
||||
pub(crate) fn send_spell_cast(
|
||||
fn send_spell_cast(
|
||||
spell_id: u32,
|
||||
ctx: &cast_target::CastContext,
|
||||
commit: CastCommit,
|
||||
commands: &NetCommands,
|
||||
self_player: &Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
spells: Option<&Spells>,
|
||||
@@ -88,9 +197,14 @@ pub(crate) fn send_spell_cast(
|
||||
// path): a spell/category cooldown refuses at the source with the client's own reason 0x3c
|
||||
// ("Spell is not ready yet.") — never sent, exactly like the real client. The GCD is
|
||||
// faithfully NOT part of this test (0x6e1690 skips the GCD fields).
|
||||
// …and the leg that forks on item-present, verbatim: the validator branches at `60952b` on
|
||||
// its item argument — item → the item cooldown query `0x6e2ed0` and reason **0x28** "Item is
|
||||
// not ready yet." (`609549`), no item → `0x6e2ea0` and **0x3c** (`609616`). (Ours keys one
|
||||
// cooldown store by spell id for both; the ref's item query keys by the item ENTRY as well —
|
||||
// a named gap, decision 0914.)
|
||||
if cooldowns.is_on_cooldown(spell_id, def, now) {
|
||||
debug!("ui_action: cast {spell_id} refused locally — on cooldown");
|
||||
cast_errors.0.push((spell_id, 0x3c));
|
||||
cast_errors.0.push((spell_id, commit.not_ready_reason()));
|
||||
return;
|
||||
}
|
||||
// The GCD leg of the local not-ready ladder ([`Cooldowns::gcd_locked`], decision 0379): a
|
||||
@@ -100,7 +214,7 @@ pub(crate) fn send_spell_cast(
|
||||
// vanished-pie bug. GCD-free presses (Heroic Strike's queue, Attack, Shoot) pass.
|
||||
if def.is_some_and(|d| cooldowns.gcd_locked(d, now)) {
|
||||
debug!("ui_action: cast {spell_id} refused locally — the GCD is running");
|
||||
cast_errors.0.push((spell_id, 0x3c));
|
||||
cast_errors.0.push((spell_id, commit.not_ready_reason()));
|
||||
return;
|
||||
}
|
||||
// The cast classes at this seam. A ranged/auto-repeat shot (Auto Shot, wand Shoot, Throw) is
|
||||
@@ -206,28 +320,43 @@ pub(crate) fn send_spell_cast(
|
||||
// ArmCast (`0x6e5250`): resolve the wire target from the spell's targeting constraints —
|
||||
// never the raw selection ([`cast_target`] module docs). A refusal is local and pre-commit,
|
||||
// like the ref's residual flag_word: no send, no GCD, no pending arm, no autorepeat key.
|
||||
let target = match cast_target::resolve_cast_target(
|
||||
def,
|
||||
ctx.selection_guid,
|
||||
ctx.self_guid,
|
||||
ctx.auto_self_cast,
|
||||
&ctx.rel,
|
||||
) {
|
||||
cast_target::CastWireTarget::SelfImplicit => None,
|
||||
cast_target::CastWireTarget::Unit(guid) => Some(guid),
|
||||
cast_target::CastWireTarget::GroundTargeting => {
|
||||
// Enter the targeting-cursor mode's location half (decision 0792) — nothing is
|
||||
// sent, nothing armed: the ref's cursor entry (`6e50c8`) runs none of the commit
|
||||
// tail; the world click's commit owes the GCD and the pending arm.
|
||||
debug!("ui_action: cast {spell_id} awaits its ground click — targeting cursor up");
|
||||
ground.enter(spell_id);
|
||||
return;
|
||||
}
|
||||
cast_target::CastWireTarget::Refused(reason) => {
|
||||
debug!("ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})");
|
||||
cast_errors.0.push((spell_id, reason));
|
||||
return;
|
||||
}
|
||||
// A caller-bound GameObject short-circuits the walk, exactly as TryCast's target resolve
|
||||
// (`6e4ef4` → `0x612df0` over the guid pair it was PASSED) takes an explicit target: the key
|
||||
// chain calls `CGItem::Use` with the lock's guid, the bag click with zero (decision 0769).
|
||||
let explicit_object = match commit {
|
||||
CastCommit::Item { on_object, .. } => on_object,
|
||||
CastCommit::Spell => None,
|
||||
};
|
||||
let target = match explicit_object {
|
||||
Some(_) => None,
|
||||
None => match cast_target::resolve_cast_target(
|
||||
def,
|
||||
ctx.selection_guid,
|
||||
ctx.self_guid,
|
||||
ctx.auto_self_cast,
|
||||
&ctx.rel,
|
||||
) {
|
||||
cast_target::CastWireTarget::SelfImplicit => None,
|
||||
cast_target::CastWireTarget::Unit(guid) => Some(guid),
|
||||
cast_target::CastWireTarget::GroundTargeting => {
|
||||
// Enter the targeting-cursor mode's location half (decision 0792) — nothing is
|
||||
// sent, nothing armed: the ref's cursor entry (`6e50c8`) runs none of the commit
|
||||
// tail; the world click's commit owes the GCD and the pending arm. The COMMIT
|
||||
// rides along: the ref keeps the whole pending-cast block (the item guid at
|
||||
// `0xceac48` included) across the cursor, so the world click's `0x6e54f0` still
|
||||
// emits USE_ITEM for a thrown grenade or a stick of dynamite.
|
||||
debug!("ui_action: cast {spell_id} awaits its ground click — targeting cursor up");
|
||||
ground.enter(spell_id, commit);
|
||||
return;
|
||||
}
|
||||
cast_target::CastWireTarget::Refused(reason) => {
|
||||
debug!(
|
||||
"ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})"
|
||||
);
|
||||
cast_errors.0.push((spell_id, reason));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
// The local range gate (the client's TryCast runs `CanTargetUnit 0x6e4440` →
|
||||
// `IsTargetInRange 0x6e47b0` BEFORE the commit `0x6e54f0`): an out-of-range / too-close
|
||||
@@ -294,11 +423,34 @@ pub(crate) fn send_spell_cast(
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::CastSpell { spell_id, target });
|
||||
// The commit's ONE branch (`SendCast 0x6e54f0`): same block, two opcodes.
|
||||
let _ = commands.0.send(match commit {
|
||||
CastCommit::Spell => ClientCommand::CastSpell { spell_id, target },
|
||||
CastCommit::Item {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
on_object,
|
||||
} => ClientCommand::UseItem {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
target: match (on_object, target) {
|
||||
(Some(go), _) => UseItemTarget::Object(go),
|
||||
(None, Some(unit)) => UseItemTarget::Unit(unit),
|
||||
(None, None) => UseItemTarget::SelfImplicit,
|
||||
},
|
||||
},
|
||||
});
|
||||
if normal_cast {
|
||||
pending.arm(spell_id, now);
|
||||
// One inflight id, every cast source (`0xceca88`). The item arm's provisional is shorter
|
||||
// because `CMSG_USE_ITEM` has legs vmangos answers with `SMSG_INVENTORY_CHANGE_FAILURE`
|
||||
// and no cast result at all — [`crate::ui_cast::PendingCast`]'s own doc, decision 0908.
|
||||
if commit.is_item() {
|
||||
pending.arm_item(spell_id, now);
|
||||
} else {
|
||||
pending.arm(spell_id, now);
|
||||
}
|
||||
} else if on_next_swing {
|
||||
queued_melee.arm(spell_id);
|
||||
}
|
||||
@@ -342,3 +494,187 @@ pub(crate) fn send_spell_cast(
|
||||
cooldowns.start_gcd(spell_id, d, now);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::Reputations;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
use crossbeam_channel::Receiver;
|
||||
|
||||
const HEARTHSTONE: u32 = 8690;
|
||||
const MOUNT: u32 = 470;
|
||||
/// The hearthstone's own wire position on a fresh character: player array, backpack slot 1.
|
||||
const HEARTH_COMMIT: CastCommit = CastCommit::Item {
|
||||
bag_index: 255,
|
||||
slot: 24,
|
||||
spell_index: 0,
|
||||
on_object: None,
|
||||
};
|
||||
|
||||
static EMPTY_REPUTATIONS: Reputations = Reputations(Vec::new());
|
||||
|
||||
/// A context with nothing selected — every rung that needs world state is inert, so these
|
||||
/// tests pin the two rungs that fork on the commit and the commit itself.
|
||||
fn ctx() -> cast_target::CastContext<'static> {
|
||||
cast_target::CastContext {
|
||||
selection_guid: None,
|
||||
self_guid: Some(0x0000_0000_0000_0007),
|
||||
auto_self_cast: false,
|
||||
rel: cast_target::TargetRelations {
|
||||
target_store: None,
|
||||
self_store: None,
|
||||
factions: None,
|
||||
reputations: &EMPTY_REPUTATIONS,
|
||||
},
|
||||
range: cast_target::RangeInputs::default(),
|
||||
self_move_flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A World carrying exactly the resources [`CastLadder`] gathers. No `Spells` — an absent
|
||||
/// catalog makes `def` `None`, which is the shape of every rung that guards on `if let
|
||||
/// Some(d) = def`: they are inert, and what is left is the ladder's spine.
|
||||
fn world() -> (World, Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut world = World::new();
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<Items>();
|
||||
world.init_resource::<crate::ui_cast::PendingCast>();
|
||||
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
|
||||
world.init_resource::<crate::cooldowns::Cooldowns>();
|
||||
world.init_resource::<CastErrors>();
|
||||
world.init_resource::<AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
|
||||
world.init_resource::<super::super::targeting::SpellTargeting>();
|
||||
// The sheath request the commit tail writes for a ranged spell — a bare World has no
|
||||
// message storage until it is asked for.
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
(world, rx)
|
||||
}
|
||||
|
||||
fn send(world: &mut World, spell_id: u32, commit: CastCommit) {
|
||||
world
|
||||
.run_system_once(move |mut ladder: CastLadder| {
|
||||
ladder.send(spell_id, &ctx(), commit);
|
||||
})
|
||||
.expect("the ladder runs as a one-shot system");
|
||||
}
|
||||
|
||||
/// **B200's regression test, at the ladder** (decisions 0908/0914): an item use is a cast
|
||||
/// through the same `TryCast` a spell press runs, so the in-flight rung (`6e4d97`) refuses the
|
||||
/// double-click — the same spell silently (`6e4d43`), a different one with 0x61 — and neither
|
||||
/// reaches the wire. Before this, the duplicate `CMSG_USE_ITEM` drew
|
||||
/// `SPELL_FAILED_SPELL_IN_PROGRESS` naming the *running* cast's own spell, which red-faded its
|
||||
/// bar while the cast completed anyway.
|
||||
#[test]
|
||||
fn a_double_clicked_item_never_ships_the_duplicate() {
|
||||
let (mut world, rx) = world();
|
||||
|
||||
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::UseItem { .. })),
|
||||
"the first click commits as USE_ITEM and arms the one inflight id"
|
||||
);
|
||||
|
||||
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
|
||||
assert!(rx.try_recv().is_err(), "no duplicate on the wire");
|
||||
assert!(
|
||||
world.resource::<CastErrors>().0.is_empty(),
|
||||
"the same spell's re-press is the ref's SILENT bail (6e4d43), not a red line"
|
||||
);
|
||||
|
||||
send(&mut world, MOUNT, HEARTH_COMMIT);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
world.resource::<CastErrors>().0,
|
||||
vec![(MOUNT, 0x61)],
|
||||
"a different spell mid-cast is 6e4d97's \"Another action is in progress\""
|
||||
);
|
||||
}
|
||||
|
||||
/// The one rung that forks on item-present: `0x6094f0`'s first leg branches at `60952b` on its
|
||||
/// item argument — item → the item cooldown query and reason **0x28** "Item is not ready yet."
|
||||
/// (`609549`); no item → the spell query and **0x3c** (`609616`). Ours is one cooldown store
|
||||
/// with two reasons. This rung used to live in the action bar's ITEM arm alone, so a bag or
|
||||
/// paper-doll click ignored cooldowns entirely and shipped a doomed packet (decision 0914).
|
||||
#[test]
|
||||
fn the_not_ready_reason_forks_on_item_present() {
|
||||
let (mut world, rx) = world();
|
||||
let use_spell = benilla_protocol::messages::ItemUseSpell {
|
||||
spell_id: HEARTHSTONE,
|
||||
cooldown_ms: 1_800_000,
|
||||
category: 0,
|
||||
category_cooldown_ms: 0,
|
||||
};
|
||||
world
|
||||
.resource_mut::<crate::cooldowns::Cooldowns>()
|
||||
.start_item(6948, &use_spell, None, Instant::now());
|
||||
|
||||
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
|
||||
assert!(rx.try_recv().is_err(), "an item on cooldown never sends");
|
||||
assert_eq!(world.resource::<CastErrors>().0, vec![(HEARTHSTONE, 0x28)]);
|
||||
|
||||
world.resource_mut::<CastErrors>().0.clear();
|
||||
send(&mut world, HEARTHSTONE, CastCommit::Spell);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
world.resource::<CastErrors>().0,
|
||||
vec![(HEARTHSTONE, 0x3c)],
|
||||
"the very same cooldown reads \"Spell is not ready yet.\" without an item"
|
||||
);
|
||||
}
|
||||
|
||||
/// The commit's own branch (`SendCast 0x6e54f0`): one ladder, one targets block, two opcodes.
|
||||
/// The key-in-a-lock arm's caller-bound GameObject short-circuits the binder and rides the
|
||||
/// block as `TARGET_FLAG_GAMEOBJECT|LOCKED` (decision 0769), which is why `on_object` is part
|
||||
/// of the commit rather than a separate send.
|
||||
#[test]
|
||||
fn the_commit_picks_the_opcode_and_the_block() {
|
||||
use benilla_protocol::messages::UseItemTarget;
|
||||
let (mut world, rx) = world();
|
||||
|
||||
send(&mut world, HEARTHSTONE, CastCommit::Spell);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEARTHSTONE,
|
||||
target: None
|
||||
})
|
||||
));
|
||||
|
||||
// `init_resource` would keep the armed guard — the point here is the commit, not the
|
||||
// in-flight rung the test above owns.
|
||||
world.insert_resource(crate::ui_cast::PendingCast::default());
|
||||
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::UseItem {
|
||||
bag_index: 255,
|
||||
slot: 24,
|
||||
spell_index: 0,
|
||||
target: UseItemTarget::SelfImplicit
|
||||
})
|
||||
));
|
||||
|
||||
world.insert_resource(crate::ui_cast::PendingCast::default());
|
||||
send(
|
||||
&mut world,
|
||||
HEARTHSTONE,
|
||||
CastCommit::Item {
|
||||
bag_index: 255,
|
||||
slot: 81,
|
||||
spell_index: 0,
|
||||
on_object: Some(0xF110_000C_1F00_A3B2),
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::UseItem {
|
||||
slot: 81,
|
||||
target: UseItemTarget::Object(0xF110_000C_1F00_A3B2),
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,21 +13,15 @@
|
||||
//! Both run `.after(UiInput)` so a click's intent goes out the same frame it was made. The two
|
||||
//! queues are disjoint per gesture, so their relative order does not matter.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM, ACTION_KIND_SPELL};
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use crate::items::Items;
|
||||
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
|
||||
use super::cast_send::send_spell_cast;
|
||||
use super::{
|
||||
attack_mounted_refusal, cast_target, AutoRepeatActive, CastErrors, PlayerActions, Spells,
|
||||
UiErrorKeys, SPELL_ATTACK,
|
||||
};
|
||||
use super::cast_send::{CastCommit, CastLadder};
|
||||
use super::{attack_mounted_refusal, cast_target, PlayerActions, UiErrorKeys, SPELL_ATTACK};
|
||||
|
||||
/// What clicking an ITEM action does, and to which copy — [`item_action_route`]'s verdict.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -87,31 +81,15 @@ pub(super) fn item_action_route(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
|
||||
pub(super) fn drain_action_uses(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
actions: Res<PlayerActions>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
commands: Res<NetCommands>,
|
||||
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
mut acquire: MessageWriter<crate::target::AttackNearestRequest>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut pending: ResMut<crate::ui_cast::PendingCast>,
|
||||
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
|
||||
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
|
||||
// The error sinks + the targeting mode, one tuple param (Bevy's 16-param ceiling): the
|
||||
// reason-coded cast line, the by-key local line, and the ground-targeting state the SPELL
|
||||
// arm's toggle-cancel and the cast path's entry both write (decision 0792).
|
||||
mut errors: (
|
||||
ResMut<CastErrors>,
|
||||
ResMut<UiErrorKeys>,
|
||||
ResMut<super::targeting::SpellTargeting>,
|
||||
),
|
||||
mut auto_repeat: ResMut<AutoRepeatActive>,
|
||||
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
|
||||
mut ecs: Commands,
|
||||
// The by-key local error line — the only sink here that is not the ladder's own
|
||||
// (`ladder.cast_errors` is the reason-coded one, `ladder.ground` the targeting mode).
|
||||
mut ui_errors: ResMut<UiErrorKeys>,
|
||||
mut ladder: CastLadder,
|
||||
) {
|
||||
let selection = &targeting.selection;
|
||||
let Some(mut script) = script else {
|
||||
@@ -127,7 +105,7 @@ pub(super) fn drain_action_uses(
|
||||
// The mounted attack block ([`attack_mounted_refusal`]): the ref's validator
|
||||
// refuses BEFORE the with-target swing and before the nearest-enemy scan
|
||||
// (`0x613039` precedes `0x6130b5`), so both arms gate here.
|
||||
if attack_mounted_refusal(targeting.self_store.iter().next(), &mut errors.1) {
|
||||
if attack_mounted_refusal(targeting.self_store.iter().next(), &mut ui_errors) {
|
||||
continue;
|
||||
}
|
||||
match selection.guid {
|
||||
@@ -138,8 +116,8 @@ pub(super) fn drain_action_uses(
|
||||
// sound: the attack path passes `(newState=1, bInstant=1, bFireEvent=1)`
|
||||
// at `0x5ecd80` (wow-re `sheath-policy.md`). The setter's idempotency
|
||||
// is the client's own "no-op if already melee".
|
||||
if let Ok((e, _)) = self_player.single() {
|
||||
sheath.write(crate::creature_anim::SheathRequest {
|
||||
if let Ok((e, _)) = ladder.self_player.single() {
|
||||
ladder.sheath.write(crate::creature_anim::SheathRequest {
|
||||
entity: e,
|
||||
state: 1,
|
||||
ceremony: false,
|
||||
@@ -147,14 +125,14 @@ pub(super) fn drain_action_uses(
|
||||
}
|
||||
// Melee attack-start cancels a running auto-repeat UNCONDITIONALLY —
|
||||
// the client's `0x5ecd8c` (wow-re `nocked-ammo-cancel.md` §Q-B-5).
|
||||
let self_e = self_player.single().ok().map(|(e, _)| e);
|
||||
let self_e = ladder.self_player.single().ok().map(|(e, _)| e);
|
||||
crate::creature_anim::cancel_auto_repeat_local(
|
||||
self_e,
|
||||
&mut auto_repeat,
|
||||
&mut ecs,
|
||||
&commands,
|
||||
&mut ladder.auto_repeat,
|
||||
&mut ladder.ecs,
|
||||
&ladder.commands,
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::AttackSwing { guid });
|
||||
let _ = ladder.commands.0.send(ClientCommand::AttackSwing { guid });
|
||||
}
|
||||
// No target: the client's attack resolver runs the nearest-enemy core and
|
||||
// swings at the winner (`0x612df0` @ `6130b5`) — `target::scan` answers.
|
||||
@@ -170,12 +148,12 @@ pub(super) fn drain_action_uses(
|
||||
// targeting cursor is up cancels the targeting instead of re-arming it —
|
||||
// press-again-to-cancel, before TryCast ever runs. (A spellbook re-press stays
|
||||
// the ref's abort-and-re-enter — it never passes through UseAction.)
|
||||
if errors.2.spell() == Some(b.action) {
|
||||
if ladder.ground.spell() == Some(b.action) {
|
||||
debug!(
|
||||
"ui_action: cast {} re-pressed — targeting toggles off",
|
||||
b.action
|
||||
);
|
||||
errors.2.clear();
|
||||
ladder.ground.clear();
|
||||
continue;
|
||||
}
|
||||
// The active-action toggle (`0x4e55f0` → the `0x4e60c1` cancel; wow-re
|
||||
@@ -183,11 +161,12 @@ pub(super) fn drain_action_uses(
|
||||
// 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(d) = ladder.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
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(crate::net::ClientCommand::CancelAura { spell_id: b.action });
|
||||
continue;
|
||||
@@ -195,23 +174,7 @@ pub(super) fn drain_action_uses(
|
||||
}
|
||||
}
|
||||
debug!("ui_action: cast {} (target {:?})", b.action, selection.guid);
|
||||
send_spell_cast(
|
||||
b.action,
|
||||
&targeting.context(),
|
||||
&commands,
|
||||
&self_player,
|
||||
spells.as_deref(),
|
||||
&items,
|
||||
&mut sheath,
|
||||
&mut ecs,
|
||||
&mut pending,
|
||||
&mut queued_melee,
|
||||
&mut cooldowns,
|
||||
&mut errors.0,
|
||||
&mut auto_repeat,
|
||||
&mut trade_skill_opens,
|
||||
&mut errors.2,
|
||||
);
|
||||
ladder.send(b.action, &targeting.context(), CastCommit::Spell);
|
||||
}
|
||||
// An item action names an item ENTRY, not a position, so the click has to find a copy
|
||||
// — [`item_action_route`] is that law. A miss (the copy left the bags between the
|
||||
@@ -222,7 +185,10 @@ pub(super) fn drain_action_uses(
|
||||
let Some(store) = targeting.self_store.iter().next() else {
|
||||
continue;
|
||||
};
|
||||
let template = items.template(b.action, 0, &commands).cloned();
|
||||
let template = ladder
|
||||
.items
|
||||
.template(b.action, 0, &ladder.commands)
|
||||
.cloned();
|
||||
// The reference reads the template first and bails on a null record; ours is all
|
||||
// but always cached by click time (the icon resolve needed it).
|
||||
let Some(template) = template else {
|
||||
@@ -233,7 +199,7 @@ pub(super) fn drain_action_uses(
|
||||
continue;
|
||||
};
|
||||
let route = item_action_route(&template, |s| {
|
||||
crate::ui_items::find_item(&store.0, &items, b.action, s)
|
||||
crate::ui_items::find_item(&store.0, &ladder.items, b.action, s)
|
||||
});
|
||||
let ((bag_index, slot0, guid), equip) = match route {
|
||||
ItemRoute::Use(pos) => (pos, false),
|
||||
@@ -253,34 +219,19 @@ pub(super) fn drain_action_uses(
|
||||
// equippable quest-starter on the bar equips, exactly as the reference does
|
||||
// (decision 0664).
|
||||
debug!("ui_action: item action {action} auto-equip (wire {bag_index}/{slot0})");
|
||||
let _ = commands.0.send(ClientCommand::AutoEquipItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::AutoEquipItem {
|
||||
bag_index,
|
||||
slot: slot0,
|
||||
});
|
||||
} else {
|
||||
// The item twin of the cast path's local not-ready refusal
|
||||
// (`IsItemOnCooldown 0x6e2fc0`: the on-use spell against the cooldown list)
|
||||
// — reason 0x28 is the client's "Item is not ready yet.".
|
||||
let on_cooldown = template.use_spell.filter(|u| {
|
||||
cooldowns.is_on_cooldown(
|
||||
u.spell_id,
|
||||
spells.as_ref().and_then(|s| s.catalog.get(u.spell_id)),
|
||||
Instant::now(),
|
||||
)
|
||||
});
|
||||
if let Some(u) = on_cooldown {
|
||||
debug!("ui_action: item action {action} refused locally — on cooldown");
|
||||
errors.0 .0.push((u.spell_id, 0x28));
|
||||
continue;
|
||||
}
|
||||
// …then the shared use fork (`CGItem::Use` — the bar's engine calls the very
|
||||
// same function at `0x4e607b`), so a quest-starter on the bar offers its quest
|
||||
// instead of a `CMSG_USE_ITEM` the server can only refuse (decision 0664). The
|
||||
// wire's third byte is the spell BLOCK ordinal, not a flag (decision 0666).
|
||||
// The fork carries the in-flight gate — an item use IS a cast through the same
|
||||
// `TryCast` (decision 0908; [`crate::ui_items::send_item_use`] is the law), so
|
||||
// the mount's second click no longer ships a duplicate the server bounces back
|
||||
// as a cast-bar cancel.
|
||||
// The fork runs the WHOLE cast ladder — an item use IS a cast through the same
|
||||
// `TryCast` (decisions 0908/0914; [`crate::ui_items::send_item_use`] is the
|
||||
// law), so the cooldown/GCD/in-flight/mounted/moving/form rungs and the local
|
||||
// "Item is not ready yet." live there now, for the bag and doll clicks too.
|
||||
let spell_index = template.use_spell_index().unwrap_or(0);
|
||||
debug!(
|
||||
"ui_action: item action {action} use (wire {bag_index}/{slot0}, spell #{spell_index})"
|
||||
@@ -293,10 +244,10 @@ pub(super) fn drain_action_uses(
|
||||
slot: slot0,
|
||||
spell_index,
|
||||
use_spell: template.use_spell.map(|u| u.spell_id),
|
||||
on_object: None,
|
||||
},
|
||||
&mut pending,
|
||||
&mut errors.0,
|
||||
&commands,
|
||||
&targeting.context(),
|
||||
&mut ladder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ mod weapon_icon;
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct CooldownEvents;
|
||||
|
||||
pub(crate) use cast_send::send_spell_cast;
|
||||
// The one cast path, and the only way in: every caster surface takes [`CastLadder`] as a single
|
||||
// SystemParam and commits through [`CastCommit`] — `send_spell_cast` itself is private to
|
||||
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
|
||||
pub(crate) use cast_send::{CastCommit, CastLadder};
|
||||
pub(crate) use errors::{
|
||||
attack_mounted_refusal, reagent_totem_refusal, ui_error_text, CastErrors, MountErrors, UiError,
|
||||
UiErrorKeys,
|
||||
|
||||
@@ -59,6 +59,11 @@ pub(crate) struct SpellTargeting(Option<GroundTargeting>);
|
||||
|
||||
struct GroundTargeting {
|
||||
spell_id: u32,
|
||||
/// What the world click will commit. The ref keeps the whole pending-cast block across the
|
||||
/// cursor — the cast **item's** guid at `0xceac48` included — so `0x6e54f0`'s discriminator
|
||||
/// still picks `CMSG_USE_ITEM` when the click lands: a thrown grenade, a stick of dynamite, a
|
||||
/// Goblin Mortar (decision 0914; 46 of the 1.12 on-use item spells arm this cursor).
|
||||
commit: super::cast_send::CastCommit,
|
||||
}
|
||||
|
||||
impl SpellTargeting {
|
||||
@@ -73,8 +78,13 @@ impl SpellTargeting {
|
||||
self.0.as_ref().map(|t| t.spell_id)
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, spell_id: u32) {
|
||||
self.0 = Some(GroundTargeting { spell_id });
|
||||
pub(crate) fn enter(&mut self, spell_id: u32, commit: super::cast_send::CastCommit) {
|
||||
self.0 = Some(GroundTargeting { spell_id, commit });
|
||||
}
|
||||
|
||||
/// What the pending ground cast will commit as when the click lands.
|
||||
fn commit(&self) -> Option<super::cast_send::CastCommit> {
|
||||
self.0.as_ref().map(|t| t.commit)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
@@ -190,7 +200,7 @@ pub(crate) fn commit_ground_cast_on_click(
|
||||
if clicks.read().last().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(spell_id) = targeting.spell() else {
|
||||
let (Some(spell_id), Some(commit)) = (targeting.spell(), targeting.commit()) else {
|
||||
return;
|
||||
};
|
||||
let Some(point) = occlusion.point else {
|
||||
@@ -203,11 +213,28 @@ pub(crate) fn commit_ground_cast_on_click(
|
||||
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2})",
|
||||
dest[0], dest[1], dest[2]
|
||||
);
|
||||
let _ = net
|
||||
.0
|
||||
.send(ClientCommand::CastSpellAtDest { spell_id, dest });
|
||||
// Same block, two opcodes — `SendCast 0x6e54f0`'s one discriminator survives the cursor
|
||||
// (decision 0914): a thrown grenade commits as `CMSG_USE_ITEM` with the DEST block.
|
||||
let _ = net.0.send(match commit {
|
||||
super::cast_send::CastCommit::Spell => ClientCommand::CastSpellAtDest { spell_id, dest },
|
||||
super::cast_send::CastCommit::Item {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
..
|
||||
} => ClientCommand::UseItem {
|
||||
bag_index,
|
||||
slot,
|
||||
spell_index,
|
||||
target: benilla_protocol::messages::UseItemTarget::Dest(dest),
|
||||
},
|
||||
});
|
||||
let now = Instant::now();
|
||||
pending.arm(spell_id, now);
|
||||
if commit.is_item() {
|
||||
pending.arm_item(spell_id, now);
|
||||
} else {
|
||||
pending.arm(spell_id, now);
|
||||
}
|
||||
if let Some(d) = spells.as_ref().and_then(|s| s.catalog.get(spell_id)) {
|
||||
cooldowns.start_gcd(spell_id, d, now);
|
||||
}
|
||||
|
||||
@@ -393,16 +393,22 @@ fn combat_stats(
|
||||
/// the same feed `ui_items::feed_containers` reads for a bag slot's own `.locked`). `slot0` is
|
||||
/// the wire `EQUIPMENT_SLOT_*`/`INV_SLOT` index (0..22 — equipment 0..18, the four equipped-bag
|
||||
/// icons 19..22); the live-API id `slot_view` reports through is `slot0 + 1` either way.
|
||||
#[allow(clippy::too_many_arguments)] // the slot resolve's full read set — the bag feed's twin
|
||||
fn slot_view(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
enchant_rows: Option<&crate::items::Enchants>,
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
slot0: u8,
|
||||
) -> Option<InvSlotView> {
|
||||
let guid = store.0.player_inv_slot(slot0)?;
|
||||
// The temp-enchant countdowns, read before the object borrow (both live on `Items`) — the bag
|
||||
// feed's twin.
|
||||
let enchant_ms: [Option<u64>; 7] =
|
||||
std::array::from_fn(|s| items.enchant_remaining_ms(guid, s as u32));
|
||||
let obj = items.object(guid)?;
|
||||
let entry = obj.object_entry()?;
|
||||
let count = obj.item_stack_count().unwrap_or(1).max(1);
|
||||
@@ -425,6 +431,20 @@ fn slot_view(
|
||||
.item_creator()
|
||||
.filter(|&g| g != 0)
|
||||
.and_then(|g| names.resolve(g, commands).map(str::to_string));
|
||||
// The item's own 7 enchant slots — the equipped tooltip's enchant lines (decision 0915). OUR
|
||||
// gear streams as item objects, so this is the full array; an INSPECTED player's tooltip sees
|
||||
// only the 2 slots their descriptor broadcasts (`ui_inspect`), as the reference does.
|
||||
let enchants = crate::items::enchant_lines(
|
||||
(0..7).map(|s| {
|
||||
(
|
||||
s,
|
||||
obj.item_enchant(s).unwrap_or(0),
|
||||
obj.item_enchant_charges(s),
|
||||
enchant_ms[usize::from(s)],
|
||||
)
|
||||
}),
|
||||
enchant_rows,
|
||||
);
|
||||
let t = items.template(entry, guid, commands);
|
||||
let (name, quality, display, link, equip_slots, class, bar_placeable) = match t {
|
||||
Some(t) => (
|
||||
@@ -469,6 +489,7 @@ fn slot_view(
|
||||
equip_slots,
|
||||
bar_placeable,
|
||||
creator,
|
||||
enchants,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -510,6 +531,7 @@ fn inventory_slots(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
enchant_rows: Option<&crate::items::Enchants>,
|
||||
commands: &NetCommands,
|
||||
pending: &PendingItemOps,
|
||||
names: &mut crate::names::NameCache,
|
||||
@@ -547,17 +569,37 @@ fn inventory_slots(
|
||||
// Ammo is a named deferral too: no drag path reaches the bar from this slot.
|
||||
bar_placeable: false,
|
||||
creator: None,
|
||||
// Ammo carries no enchant slots to read: the wire never streams an ammo instance
|
||||
// here, only the template id off the player descriptor.
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
}
|
||||
for slot in 1..=19u8 {
|
||||
inv[usize::from(slot)] = slot_view(store, items, icons, commands, pending, names, slot - 1);
|
||||
inv[usize::from(slot)] = slot_view(
|
||||
store,
|
||||
items,
|
||||
icons,
|
||||
enchant_rows,
|
||||
commands,
|
||||
pending,
|
||||
names,
|
||||
slot - 1,
|
||||
);
|
||||
}
|
||||
// Bag0Slot..Bag3Slot (ids 20..23, PaperDollItemFrame.dbc-verified) — the bag bar's icon
|
||||
// source: the equipped bag ITEM at INV_SLOT 19..22 (BAG_SLOT_FIRST../SLOT_BAG_FIRST in the
|
||||
// container feed's own numbering), same `slot_view` resolution as any equipment slot.
|
||||
for (i, slot) in (20u8..=23).enumerate() {
|
||||
inv[usize::from(slot)] =
|
||||
slot_view(store, items, icons, commands, pending, names, 19 + i as u8);
|
||||
inv[usize::from(slot)] = slot_view(
|
||||
store,
|
||||
items,
|
||||
icons,
|
||||
enchant_rows,
|
||||
commands,
|
||||
pending,
|
||||
names,
|
||||
19 + i as u8,
|
||||
);
|
||||
}
|
||||
inv
|
||||
}
|
||||
@@ -568,6 +610,8 @@ fn feed_char(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut items: ResMut<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915).
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut feed: ResMut<CharFeedState>,
|
||||
mut booth: ResMut<PaperDollBooth>,
|
||||
@@ -692,6 +736,7 @@ fn feed_char(
|
||||
store,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchants.as_deref(),
|
||||
&commands,
|
||||
&pending,
|
||||
&mut names,
|
||||
|
||||
@@ -27,11 +27,11 @@ use benilla_ui::script::{CraftReagent, CraftRecipe, CraftState, UiScript};
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{cast_target, send_spell_cast, PlayerActions, Spells};
|
||||
use crate::ui_action::{cast_target, CastCommit, CastLadder, PlayerActions, Spells};
|
||||
use crate::ui_items::count_of;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_spellbook::SkillLines;
|
||||
use crate::ui_tradeskill::{SpellFocus, TradeSkillOpens};
|
||||
use crate::ui_tradeskill::SpellFocus;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
|
||||
/// `SPELL_ATTR_IS_TRADESKILL` — the item-recipe marker (0227's add-gate bit). Craft recipes
|
||||
@@ -59,18 +59,6 @@ pub(crate) struct PendingItemCast {
|
||||
pub(crate) spell_id: u32,
|
||||
}
|
||||
|
||||
/// The one-cast-send-path resource set `drain_craft` threads into `send_spell_cast`, tupled to
|
||||
/// fit Bevy's 16-param ceiling (the apply-side grouped-tuple precedent).
|
||||
type CastTail<'w> = (
|
||||
ResMut<'w, crate::ui_cast::PendingCast>,
|
||||
ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
|
||||
ResMut<'w, crate::cooldowns::Cooldowns>,
|
||||
ResMut<'w, crate::ui_action::CastErrors>,
|
||||
ResMut<'w, crate::ui_action::AutoRepeatActive>,
|
||||
ResMut<'w, TradeSkillOpens>,
|
||||
ResMut<'w, crate::ui_action::SpellTargeting>,
|
||||
);
|
||||
|
||||
pub(crate) struct UiCraftPlugin;
|
||||
|
||||
impl Plugin for UiCraftPlugin {
|
||||
@@ -275,32 +263,15 @@ fn feed_craft(
|
||||
/// Drain the Lua intents: `DoCraft` on an item-targeted enchant ARMS the pick ([`PendingItemCast`]
|
||||
/// — the container drain completes it); on a self-craft (rod making) casts through the one
|
||||
/// cast-send path immediately. `CloseCraft` closes the window and disarms any pick.
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set (send_spell_cast's own)
|
||||
fn drain_craft(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<CraftOpen>,
|
||||
mut pending_pick: ResMut<PendingItemCast>,
|
||||
spells: Option<Res<Spells>>,
|
||||
skill_lines: Option<Res<SkillLines>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
items: Res<Items>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
commands: Res<NetCommands>,
|
||||
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
// The cast-send tail's resource set, tupled (Bevy's 16-param ceiling; one concern).
|
||||
cast_tail: CastTail,
|
||||
mut ecs: Commands,
|
||||
mut ladder: CastLadder,
|
||||
) {
|
||||
let (
|
||||
mut pending,
|
||||
mut queued_melee,
|
||||
mut cooldowns,
|
||||
mut cast_errors,
|
||||
mut auto_repeat,
|
||||
mut trade_opens,
|
||||
mut ground,
|
||||
) = cast_tail;
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
@@ -310,7 +281,8 @@ fn drain_craft(
|
||||
debug!("ui_craft: DoCraft({spell_id}) with no open window — ignored");
|
||||
continue;
|
||||
}
|
||||
let needs_item = spells
|
||||
let needs_item = ladder
|
||||
.spells
|
||||
.as_deref()
|
||||
.and_then(|s| s.catalog.get(spell_id))
|
||||
.map(|d| {
|
||||
@@ -327,23 +299,7 @@ fn drain_craft(
|
||||
Some(false) => {
|
||||
debug!("ui_craft: DoCraft({spell_id}) — self craft");
|
||||
pending_pick.spell_id = 0;
|
||||
send_spell_cast(
|
||||
spell_id,
|
||||
&targeting.context(),
|
||||
&commands,
|
||||
&self_player,
|
||||
spells.as_deref(),
|
||||
&items,
|
||||
&mut sheath,
|
||||
&mut ecs,
|
||||
&mut pending,
|
||||
&mut queued_melee,
|
||||
&mut cooldowns,
|
||||
&mut cast_errors,
|
||||
&mut auto_repeat,
|
||||
&mut trade_opens,
|
||||
&mut ground,
|
||||
);
|
||||
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
|
||||
}
|
||||
None => debug!("ui_craft: DoCraft({spell_id}) — unknown spell, ignored"),
|
||||
}
|
||||
@@ -365,14 +321,17 @@ fn drain_craft(
|
||||
let guid = self_store
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &items));
|
||||
.and_then(|store| crate::ui_items::slot_guid(&store.0, bag, slot0, &ladder.items));
|
||||
match guid {
|
||||
Some(item_guid) => {
|
||||
debug!("ui_craft: enchant pick — cast {armed} at item {item_guid:#x}");
|
||||
let _ = commands.0.send(crate::net::ClientCommand::CastSpellItem {
|
||||
spell_id: armed,
|
||||
item_guid,
|
||||
});
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(crate::net::ClientCommand::CastSpellItem {
|
||||
spell_id: armed,
|
||||
item_guid,
|
||||
});
|
||||
}
|
||||
None => debug!("ui_craft: enchant pick on an empty slot — kept armed"),
|
||||
}
|
||||
|
||||
@@ -105,10 +105,17 @@ impl Plugin for InspectUiPlugin {
|
||||
/// Everything an item *object* would supply is absent by construction (see the module doc), so the
|
||||
/// count is the ref's own always-1 and durability/flags/locks/creator/equip-fit stay at their inert
|
||||
/// defaults — an inspected item is not draggable, lockable, or repairable.
|
||||
///
|
||||
/// The **enchant lines are the one exception** (decision 0915): a unit's descriptor broadcasts the
|
||||
/// enchants of what it wears — `PLAYER_VISIBLE_ITEM_<slot>_0 + 1 + j` — so an inspected weapon's
|
||||
/// enchant is readable without any object. 1.12 fills exactly two of those seven slots (PERM,
|
||||
/// TEMP: vmangos `SetVisibleItemSlot`'s `MAX_INSPECTED_ENCHANTMENT_SLOT`), and that is the same
|
||||
/// data the reference's own inspect tooltip has.
|
||||
fn inspect_slot_view(
|
||||
store: &ObjectStore,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
enchant_rows: Option<&crate::items::Enchants>,
|
||||
commands: &NetCommands,
|
||||
slot0: u8,
|
||||
) -> Option<InvSlotView> {
|
||||
@@ -129,6 +136,17 @@ fn inspect_slot_view(
|
||||
quality: quality as i32,
|
||||
name,
|
||||
link,
|
||||
// All 7 slots, exactly as the reference's own inspect leg copies and renders them
|
||||
// (§E7) — a 1.12 server happens to fill only PERM and TEMP. No item object here, so no
|
||||
// charges and no `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdown: the reference's inspect
|
||||
// tooltip has neither either.
|
||||
enchants: crate::items::enchant_lines(
|
||||
(0..7).map(|j| {
|
||||
let id = store.0.player_visible_item_enchant(slot0, j).unwrap_or(0);
|
||||
(j, id as i32, 0, None)
|
||||
}),
|
||||
enchant_rows,
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -141,6 +159,8 @@ fn feed_inspect(
|
||||
mut booth: ResMut<InspectBooth>,
|
||||
mut items: ResMut<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// `SpellItemEnchantment`'s name column — the inspected item's enchant line (decision 0915).
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
commands: Res<NetCommands>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
@@ -236,8 +256,14 @@ fn feed_inspect(
|
||||
};
|
||||
let mut slots: InventorySlots = Default::default();
|
||||
for slot in 1..=19u8 {
|
||||
slots[usize::from(slot)] =
|
||||
inspect_slot_view(store, &mut items, icons.as_deref(), &commands, slot - 1);
|
||||
slots[usize::from(slot)] = inspect_slot_view(
|
||||
store,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchants.as_deref(),
|
||||
&commands,
|
||||
slot - 1,
|
||||
);
|
||||
}
|
||||
let view = InspectView {
|
||||
unit: token.clone(),
|
||||
|
||||
@@ -80,11 +80,9 @@ pub(super) fn drain_container_autoequips(
|
||||
/// but a stray Lua call is still refused rather than sent as nonsense.
|
||||
pub(super) fn drain_inventory_uses(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut items: ResMut<Items>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut pending: ResMut<crate::ui_cast::PendingCast>,
|
||||
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
|
||||
targeting: crate::ui_action::cast_target::CastTargeting,
|
||||
mut ladder: crate::ui_action::CastLadder,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -100,10 +98,10 @@ pub(super) fn drain_inventory_uses(
|
||||
let (guid, start_quest, spell_index, use_spell) = self_q
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &items))
|
||||
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &ladder.items))
|
||||
.and_then(|guid| {
|
||||
let entry = items.object(guid)?.object_entry()?;
|
||||
let t = items.template(entry, guid, &commands)?;
|
||||
let entry = ladder.items.object(guid)?.object_entry()?;
|
||||
let t = ladder.items.template(entry, guid, &ladder.commands)?;
|
||||
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
|
||||
// template is already in hand here for `start_quest`, so name the real one.
|
||||
Some((
|
||||
@@ -123,10 +121,10 @@ pub(super) fn drain_inventory_uses(
|
||||
slot,
|
||||
spell_index,
|
||||
use_spell,
|
||||
on_object: None,
|
||||
},
|
||||
&mut pending,
|
||||
&mut cast_errors,
|
||||
&commands,
|
||||
&targeting.context(),
|
||||
&mut ladder,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,15 +132,13 @@ pub(super) fn drain_inventory_uses(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn drain_container_uses(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut items: ResMut<Items>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
merchant: Res<crate::ui_merchant::MerchantOpen>,
|
||||
bank: Res<crate::ui_bank::BankOpen>,
|
||||
mut equip_sound: MessageWriter<crate::sound::AutoEquipSound>,
|
||||
mut item_text: ResMut<crate::ui_item_text::ItemTextOpen>,
|
||||
mut pending: ResMut<crate::ui_cast::PendingCast>,
|
||||
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
|
||||
targeting: crate::ui_action::cast_target::CastTargeting,
|
||||
mut ladder: crate::ui_action::CastLadder,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -158,11 +154,11 @@ pub(super) fn drain_container_uses(
|
||||
let item_guid = self_q
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|store| slot_guid(&store.0, bag, slot0, &items));
|
||||
.and_then(|store| slot_guid(&store.0, bag, slot0, &ladder.items));
|
||||
match item_guid {
|
||||
Some(guid) => {
|
||||
debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})");
|
||||
let _ = commands.0.send(ClientCommand::RepairItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::RepairItem {
|
||||
vendor,
|
||||
item_guid: guid,
|
||||
});
|
||||
@@ -181,11 +177,11 @@ pub(super) fn drain_container_uses(
|
||||
let item_guid = self_q
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items));
|
||||
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.items));
|
||||
match item_guid {
|
||||
Some(guid) => {
|
||||
debug!("ui_items: sell lua bag {bag} slot {slot} (item {guid:#x})");
|
||||
let _ = commands.0.send(ClientCommand::SellItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::SellItem {
|
||||
vendor,
|
||||
item_guid: guid,
|
||||
count: 0,
|
||||
@@ -211,13 +207,13 @@ pub(super) fn drain_container_uses(
|
||||
let withdrawing = bag == super::BANK_CONTAINER || (5..=10).contains(&bag);
|
||||
if withdrawing {
|
||||
debug!("ui_items: withdraw (lua bag {bag} → wire {bag_index}/{wire_slot})");
|
||||
let _ = commands.0.send(ClientCommand::AutoStoreBankItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::AutoStoreBankItem {
|
||||
bag: bag_index,
|
||||
slot: wire_slot,
|
||||
});
|
||||
} else {
|
||||
debug!("ui_items: deposit (lua bag {bag} → wire {bag_index}/{wire_slot})");
|
||||
let _ = commands.0.send(ClientCommand::AutoBankItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::AutoBankItem {
|
||||
bag: bag_index,
|
||||
slot: wire_slot,
|
||||
});
|
||||
@@ -231,13 +227,13 @@ pub(super) fn drain_container_uses(
|
||||
let clicked = self_q
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &items))
|
||||
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.items))
|
||||
.and_then(|guid| {
|
||||
let obj = items.object(guid)?;
|
||||
let obj = ladder.items.object(guid)?;
|
||||
let inst_flags = obj.item_flags().unwrap_or(0);
|
||||
let item_text_id = obj.item_text_id().unwrap_or(0);
|
||||
let entry = obj.object_entry()?;
|
||||
let t = items.template(entry, guid, &commands)?;
|
||||
let t = ladder.items.template(entry, guid, &ladder.commands)?;
|
||||
Some(Clicked {
|
||||
guid,
|
||||
entry,
|
||||
@@ -277,10 +273,13 @@ pub(super) fn drain_container_uses(
|
||||
"ui_items: set ammo entry {} (lua bag {bag} slot {slot})",
|
||||
c.entry
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::SetAmmo { entry: c.entry });
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(ClientCommand::SetAmmo { entry: c.entry });
|
||||
} else {
|
||||
debug!("ui_items: auto-equip (lua bag {bag} → wire {bag_index}/{wire_slot})");
|
||||
let _ = commands.0.send(ClientCommand::AutoEquipItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::AutoEquipItem {
|
||||
bag_index,
|
||||
slot: wire_slot,
|
||||
});
|
||||
@@ -299,7 +298,7 @@ pub(super) fn drain_container_uses(
|
||||
"ui_items: unwrap gift {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
|
||||
c.guid
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::OpenItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::OpenItem {
|
||||
bag_index,
|
||||
slot: wire_slot,
|
||||
});
|
||||
@@ -323,10 +322,10 @@ pub(super) fn drain_container_uses(
|
||||
slot: wire_slot,
|
||||
spell_index: c.spell_index,
|
||||
use_spell: c.use_spell,
|
||||
on_object: None,
|
||||
},
|
||||
&mut pending,
|
||||
&mut cast_errors,
|
||||
&commands,
|
||||
&targeting.context(),
|
||||
&mut ladder,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -366,7 +365,7 @@ pub(super) fn drain_container_uses(
|
||||
"ui_items: open item {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
|
||||
c.guid
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::OpenItem {
|
||||
let _ = ladder.commands.0.send(ClientCommand::OpenItem {
|
||||
bag_index,
|
||||
slot: wire_slot,
|
||||
});
|
||||
@@ -383,10 +382,10 @@ pub(super) fn drain_container_uses(
|
||||
slot: wire_slot,
|
||||
spell_index: clicked.map_or(0, |c| c.spell_index),
|
||||
use_spell: clicked.and_then(|c| c.use_spell),
|
||||
on_object: None,
|
||||
},
|
||||
&mut pending,
|
||||
&mut cast_errors,
|
||||
&commands,
|
||||
&targeting.context(),
|
||||
&mut ladder,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,45 @@
|
||||
//! inventory half of the refusal-message law (`ui_action::errors`: every string comes from the
|
||||
//! VM's own loaded `GlobalStrings.lua`, never hardcoded; `mount_result_key` is the twin table).
|
||||
//! The drain lives in [`super::feed::feed_containers`].
|
||||
//!
|
||||
//! **Not every `SMSG_INVENTORY_CHANGE_FAILURE` is an error to show, and the client has no
|
||||
//! special case that says so.** The packet carries two jobs — clear the item's client-side
|
||||
//! pending ("gray") lock, and *maybe* print a red line. Reason 59 (`EQUIP_ERR_NONE`) is the
|
||||
//! server's pure sentinel for the first: vmangos sends it verbatim as *"free gray item after use
|
||||
//! fail"* (`SpellHandler.cpp:130`, the shapeshifted item-use refusal) and *"just remove gray
|
||||
//! item state"* (`ItemHandler.cpp:838`/`:936`). It rides *alongside* the real message — a
|
||||
//! shapeshifted mount press answers 59 **and** `SPELL_FAILED_NO_ITEMS_WHILE_SHAPESHIFTED` — so
|
||||
//! anything printed for it doubles the refusal (director's report B198).
|
||||
//!
|
||||
//! The reference silences it **through the data, not through control flow** (wow-re
|
||||
//! `system/ui/scratch/inventory-change-failure-display.md`, §5 trio, dde90376). Handler
|
||||
//! `0x5e3991` maps the reason through the 67-wide errorId jump table `0x622794` into the error
|
||||
//! registry `0xb4b498` and calls `CGGameUI::DisplayError 0x496720` unconditionally. 59's slot is
|
||||
//! populated and non-null — errorId 362, key `ERR_CANT_BE_DISENCHANTED` — but that key is simply
|
||||
//! **absent from the shipped 1.12 `GlobalStrings.lua`**, so `GetText` hands back the pre-seeded
|
||||
//! empty string and the sink's `cmp byte [ecx],0` guard at `0x4945b4` returns before anything
|
||||
//! renders or sounds. So the table below is total, and *silence is what an unresolvable key
|
||||
//! does* — the same law the cast path already runs (`ui_action::cast_fail`). Reason **0** is the
|
||||
//! only coded suppression (`0x5e39a9`), and it is already gated a layer up, at
|
||||
//! `benilla_protocol::events::decode` (`if reason != 0`), so it never reaches this table.
|
||||
|
||||
/// A wire `InventoryResult` refusal → its GlobalStrings key, resolved to text through the VM's
|
||||
/// own loaded `GlobalStrings.lua` at the drain. The FULL build-5875 table: codes VERIFIED
|
||||
/// against vmangos `ItemDefines.h` (the wire's author — sequential with every `#if` band ≤ 5875
|
||||
/// included: stunned=37, dead=38, INVENTORY_FULL=50; an earlier table had the TBC-era 39/40 for
|
||||
/// stunned/dead — on this wire those are CANT_DO_RIGHT_NOW / INT_BAG_ERROR); keys are the
|
||||
/// GlobalStrings tag each enum entry carries, every one verified present in the shipped 1.12
|
||||
/// `GlobalStrings.lua` (patch-2.MPQ — the resolution test below). Reason 1's string carries a
|
||||
/// `%d` the drain fills with the packet's required level. `None` — code 59 (`EQUIP_ERR_NONE`,
|
||||
/// whose ERR_CANT_BE_DISENCHANTED tag has no 1.12 string) and anything past the enum — gets the
|
||||
/// drain's hex debug line.
|
||||
pub(super) fn equip_error_key(reason: u8) -> Option<&'static str> {
|
||||
Some(match reason {
|
||||
/// own loaded `GlobalStrings.lua` at the drain. **Total** — every reason has a key, because the
|
||||
/// reference's own lookup is (see the module doc); a key with no string displays nothing.
|
||||
///
|
||||
/// The FULL build-5875 set, VERIFIED key-by-key against the binary's errorId table `0x622794`
|
||||
/// (wow-re, above) — which agreed with the vmangos `ItemDefines.h` tag comments on 66 of 67
|
||||
/// entries. Positions come from that enum (sequential with every `#if` band ≤ 5875 included:
|
||||
/// stunned=37, dead=38, INVENTORY_FULL=50; an earlier table had the TBC-era 39/40 for
|
||||
/// stunned/dead — on this wire those are CANT_DO_RIGHT_NOW / INT_BAG_ERROR). Reason 1's string
|
||||
/// carries a `%d` the drain fills with the packet's required level.
|
||||
pub(super) fn equip_error_key(reason: u8) -> &'static str {
|
||||
match reason {
|
||||
1 => "ERR_CANT_EQUIP_LEVEL_I",
|
||||
2 => "ERR_CANT_EQUIP_SKILL",
|
||||
3 => "ERR_WRONG_SLOT",
|
||||
// 4 BAG_FULL; 51 BANK_FULL and the BAG_FULL3/4/6 aliases all read ERR_BAG_FULL.
|
||||
4 | 51 | 53 | 56 | 62 => "ERR_BAG_FULL",
|
||||
// 4 BAG_FULL and the BAG_FULL3/4/6 aliases. **51 is NOT one of them** — see below.
|
||||
4 | 53 | 56 | 62 => "ERR_BAG_FULL",
|
||||
5 => "ERR_BAG_IN_BAG",
|
||||
6 => "ERR_TRADE_EQUIPPED_BAG",
|
||||
7 => "ERR_AMMO_ONLY",
|
||||
@@ -63,16 +84,30 @@ pub(super) fn equip_error_key(reason: u8) -> Option<&'static str> {
|
||||
48 => "ERR_CANT_WRAP_BAGS",
|
||||
49 => "ERR_LOOT_GONE",
|
||||
50 => "ERR_INV_FULL",
|
||||
// 51 `EQUIP_ERR_BANK_FULL` — "Your bank is full", NOT the bag line. vmangos's own enum
|
||||
// annotates this entry `// ERR_BAG_FULL`, and that comment is simply **wrong**: the
|
||||
// binary's pad sets errorId 1 (`0x622661 mov eax,1`), whose registry slot is
|
||||
// `ERR_BANK_FULL` (init `0x484cda`, key ptr `0x842180`). We inherited the bad comment
|
||||
// when this table was built from the tag list; the binary is the authority.
|
||||
51 => "ERR_BANK_FULL",
|
||||
52 | 57 => "ERR_VENDOR_SOLD_OUT",
|
||||
58 => "ERR_OBJECT_IS_BUSY",
|
||||
// 59 `EQUIP_ERR_NONE` — the lock-clear sentinel. Its key is real and its registry slot
|
||||
// is populated; the key just has no 1.12 string, so it renders as nothing (module doc,
|
||||
// B198). Mapped rather than special-cased, because that is exactly what the client does.
|
||||
59 => "ERR_CANT_BE_DISENCHANTED",
|
||||
60 => "ERR_NOT_IN_COMBAT",
|
||||
61 => "ERR_NOT_WHILE_DISARMED",
|
||||
63 => "ERR_CANT_EQUIP_RANK",
|
||||
64 => "ERR_CANT_EQUIP_REPUTATION",
|
||||
65 => "ERR_TOO_MANY_SPECIAL_BAGS",
|
||||
66 => "ERR_LOOT_CANT_LOOT_THAT_NOW",
|
||||
_ => return None,
|
||||
})
|
||||
// Past the enum. The binary's jump table is bounded `cmp ecx,0x42; ja 0x62278d` — 0..=66
|
||||
// indexed, everything above falling to the pad's default **errorId 9 = ERR_BAG_FULL**
|
||||
// ("That bag is full."). The enum's own trailing comment says the same: any greater
|
||||
// value shows as bag full. Not a hole, and not our old hex debug line.
|
||||
_ => "ERR_BAG_FULL",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -85,22 +120,54 @@ mod tests {
|
||||
/// an earlier table had the TBC-era 39/40, which here mean "right now"/"Internal Bag Error".
|
||||
#[test]
|
||||
fn equip_error_table_matches_the_5875_enum() {
|
||||
assert_eq!(equip_error_key(50), Some("ERR_INV_FULL"));
|
||||
assert_eq!(equip_error_key(37), Some("ERR_GENERIC_STUNNED"));
|
||||
assert_eq!(equip_error_key(38), Some("ERR_PLAYER_DEAD"));
|
||||
assert_eq!(equip_error_key(39), Some("ERR_CLIENT_LOCKED_OUT"));
|
||||
assert_eq!(equip_error_key(40), Some("ERR_INTERNAL_BAG_ERROR"));
|
||||
assert_eq!(equip_error_key(1), Some("ERR_CANT_EQUIP_LEVEL_I"));
|
||||
// 59 (EQUIP_ERR_NONE → ERR_CANT_BE_DISENCHANTED) has no 1.12 string; 67 is past the
|
||||
// 5875 enum — both take the drain's hex debug fallback.
|
||||
assert_eq!(equip_error_key(59), None);
|
||||
assert_eq!(equip_error_key(67), None);
|
||||
assert_eq!(equip_error_key(50), "ERR_INV_FULL");
|
||||
assert_eq!(equip_error_key(37), "ERR_GENERIC_STUNNED");
|
||||
assert_eq!(equip_error_key(38), "ERR_PLAYER_DEAD");
|
||||
assert_eq!(equip_error_key(39), "ERR_CLIENT_LOCKED_OUT");
|
||||
assert_eq!(equip_error_key(40), "ERR_INTERNAL_BAG_ERROR");
|
||||
assert_eq!(equip_error_key(1), "ERR_CANT_EQUIP_LEVEL_I");
|
||||
}
|
||||
|
||||
/// The RUNTIME leg on the real data (the `mount_result_key` test's pattern): every key the
|
||||
/// table can emit resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` —
|
||||
/// the guard against a typo'd key degrading a real refusal to the hex debug line. Also pins
|
||||
/// the director's repro end-to-end (0x32 → "Inventory is full.") and reason 1's `%d` fill.
|
||||
/// **51 is the bank, not a bag.** The one place the binary's errorId table (`0x622794` →
|
||||
/// errorId 1 → `ERR_BANK_FULL`) disagreed with the vmangos tag comments this table was first
|
||||
/// built from: `ItemDefines.h` annotates `EQUIP_ERR_BANK_FULL` `// ERR_BAG_FULL`, and that
|
||||
/// comment is wrong. A full bank used to say "That bag is full."
|
||||
#[test]
|
||||
fn a_full_bank_says_bank_not_bag() {
|
||||
assert_eq!(equip_error_key(51), "ERR_BANK_FULL");
|
||||
// Its neighbours really are the bag aliases — the correction is surgical.
|
||||
for alias in [4u8, 53, 56, 62] {
|
||||
assert_eq!(equip_error_key(alias), "ERR_BAG_FULL", "reason {alias}");
|
||||
}
|
||||
}
|
||||
|
||||
/// **B198's regression pin, and the shape of the silence.** Mounting in cat form answers
|
||||
/// `SPELL_FAILED_NO_ITEMS_WHILE_SHAPESHIFTED` *and* an inventory failure 59 —
|
||||
/// `HandleUseItemOpcode`'s "free gray item after use fail" (vmangos `SpellHandler.cpp:130`).
|
||||
/// 59 must print NOTHING or the player gets the refusal twice. The client has no special
|
||||
/// case for it: the key is real and mapped, and it is the *absent GlobalStrings entry* that
|
||||
/// silences it — pinned end-to-end in the resolution test below.
|
||||
#[test]
|
||||
fn the_lock_clear_sentinel_maps_to_the_stringless_key() {
|
||||
assert_eq!(equip_error_key(59), "ERR_CANT_BE_DISENCHANTED");
|
||||
}
|
||||
|
||||
/// Past the enum the binary clamps rather than falling through: its jump table is bounded
|
||||
/// `cmp ecx,0x42; ja`, and the default pad is errorId 9 = `ERR_BAG_FULL`. Ours used to print
|
||||
/// a hex debug line here.
|
||||
#[test]
|
||||
fn codes_past_the_enum_clamp_to_the_bag_full_default() {
|
||||
assert_eq!(equip_error_key(67), "ERR_BAG_FULL");
|
||||
assert_eq!(equip_error_key(0xFF), "ERR_BAG_FULL");
|
||||
}
|
||||
|
||||
/// The RUNTIME leg on the real data (the `mount_result_key` test's pattern), and the load-
|
||||
/// bearing one now that the drain has no runtime fallback: every key the table can emit
|
||||
/// resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` — **except reason
|
||||
/// 59's**, which must resolve to nothing, because that absence is the entire mechanism by
|
||||
/// which B198's duplicate line stays off the screen. A typo'd key would now silently swallow
|
||||
/// a real refusal; this test is what catches it. Also pins the director's earlier repro
|
||||
/// end-to-end (0x32 → "Inventory is full."), reason 1's `%d` fill, and 51's bank line.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn every_equip_error_key_resolves_in_the_real_global_strings() {
|
||||
@@ -117,20 +184,30 @@ mod tests {
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
|
||||
|
||||
for reason in 0..=66u8 {
|
||||
let Some(key) = equip_error_key(reason) else {
|
||||
continue; // 0=OK (filtered upstream), 59 (no 1.12 string)
|
||||
};
|
||||
// Reason 0 never reaches this table (gated at `events::decode`), so the sweep starts at 1.
|
||||
for reason in 1..=66u8 {
|
||||
let key = equip_error_key(reason);
|
||||
let text = g(key).unwrap_or_default();
|
||||
if reason == 59 {
|
||||
// THE mechanism, asserted on the real shipped data rather than assumed: the key
|
||||
// is mapped and the lookup happens, and it comes back empty. `0x4945b4`'s guard
|
||||
// is what the drain's `is_empty()` continue mirrors.
|
||||
assert!(
|
||||
text.is_empty(),
|
||||
"reason 59's {key} resolved to {text:?} — B198's duplicate line is back"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert!(!text.is_empty(), "{key} (reason {reason}) missing");
|
||||
}
|
||||
assert_eq!(g(equip_error_key(50)).unwrap(), "Inventory is full.");
|
||||
assert_eq!(
|
||||
g(equip_error_key(50).unwrap()).unwrap(),
|
||||
"Inventory is full."
|
||||
);
|
||||
assert_eq!(
|
||||
g(equip_error_key(1).unwrap()).unwrap().replace("%d", "30"),
|
||||
g(equip_error_key(1)).unwrap().replace("%d", "30"),
|
||||
"You must reach level 30 to use that item."
|
||||
);
|
||||
// The 51 correction, in the words the player actually sees.
|
||||
assert_eq!(g(equip_error_key(51)).unwrap(), "Your bank is full");
|
||||
// The past-the-enum clamp resolves too — it is a real line, not a placeholder.
|
||||
assert_eq!(g(equip_error_key(67)).unwrap(), "That bag is full.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ fn template_view(
|
||||
page_text: t.page_text,
|
||||
sell_price: t.sell_price,
|
||||
item_set: t.item_set,
|
||||
random_property: t.random_property,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +414,7 @@ fn resolve_slot(
|
||||
guid: u64,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
enchants: Option<&crate::items::Enchants>,
|
||||
commands: &NetCommands,
|
||||
cooldowns: &crate::cooldowns::Cooldowns,
|
||||
spells: Option<&benilla_formats::SpellCatalog>,
|
||||
@@ -423,34 +425,54 @@ fn resolve_slot(
|
||||
if guid == 0 {
|
||||
return None;
|
||||
}
|
||||
let (entry, count, durability, readable, creator, flags) = match items.object(guid) {
|
||||
Some(fields) => (
|
||||
fields.object_entry().unwrap_or(0),
|
||||
fields.item_stack_count().unwrap_or(1),
|
||||
// The live instance pair — the wire updates ITEM_FIELD_DURABILITY on damage/repair
|
||||
// (death 10%, spirit healer 25%); max 0 = indestructible ⇒ no line.
|
||||
fields
|
||||
.item_durability()
|
||||
.zip(fields.item_max_durability())
|
||||
.filter(|&(_, max)| max > 0),
|
||||
// The instance carries letter text (a mail permanent copy) — the same gate the
|
||||
// right-click reader fork uses (`ui_items::drain`); the hover magnifier keys off it.
|
||||
// Template-`PageText` books join when the books read path lands (decision 0572).
|
||||
fields.item_text_id().is_some_and(|id| id != 0),
|
||||
// `ITEM_FIELD_CREATOR` → the ask-once name cache: the tooltip's "Written by %s" /
|
||||
// "<Made by %s>" line. `None` while the query is in flight — the changed re-push
|
||||
// repaints the hover when the answer lands (the ref's cache-callback shape).
|
||||
fields
|
||||
.item_creator()
|
||||
.filter(|&g| g != 0)
|
||||
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
|
||||
// `ITEM_FIELD_FLAGS` — the tooltip's UNLOCKED (0x4) / WRAPPED (0x8) sub-gates.
|
||||
fields.item_flags().unwrap_or(0),
|
||||
),
|
||||
// The player descriptor references a guid whose create hasn't landed (yet) — occupied,
|
||||
// unresolved.
|
||||
None => return Some(ContainerSlot::default()),
|
||||
};
|
||||
// The temporary-enchant countdowns, read off the deadline store BEFORE the object borrow below
|
||||
// (both live on `Items`): one `Option<ms>` per enchant slot.
|
||||
let enchant_ms: [Option<u64>; 7] =
|
||||
std::array::from_fn(|s| items.enchant_remaining_ms(guid, s as u32));
|
||||
let (entry, count, durability, readable, creator, flags, enchant_lines) =
|
||||
match items.object(guid) {
|
||||
Some(fields) => (
|
||||
fields.object_entry().unwrap_or(0),
|
||||
fields.item_stack_count().unwrap_or(1),
|
||||
// The live instance pair — the wire updates ITEM_FIELD_DURABILITY on damage/repair
|
||||
// (death 10%, spirit healer 25%); max 0 = indestructible ⇒ no line.
|
||||
fields
|
||||
.item_durability()
|
||||
.zip(fields.item_max_durability())
|
||||
.filter(|&(_, max)| max > 0),
|
||||
// The instance carries letter text (a mail permanent copy) — the same gate the
|
||||
// right-click reader fork uses (`ui_items::drain`); the hover magnifier keys off it.
|
||||
// Template-`PageText` books join when the books read path lands (decision 0572).
|
||||
fields.item_text_id().is_some_and(|id| id != 0),
|
||||
// `ITEM_FIELD_CREATOR` → the ask-once name cache: the tooltip's "Written by %s" /
|
||||
// "<Made by %s>" line. `None` while the query is in flight — the changed re-push
|
||||
// repaints the hover when the answer lands (the ref's cache-callback shape).
|
||||
fields
|
||||
.item_creator()
|
||||
.filter(|&g| g != 0)
|
||||
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
|
||||
// `ITEM_FIELD_FLAGS` — the tooltip's UNLOCKED (0x4) / WRAPPED (0x8) sub-gates.
|
||||
fields.item_flags().unwrap_or(0),
|
||||
// The instance's own 7 enchant slots — the tooltip's enchant lines (0915/0920). An
|
||||
// item we hold streams as an OBJECT, so all seven are here, with their charges and
|
||||
// their `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdowns; the wire's 2-slot broadcast is
|
||||
// what everyone ELSE's items are limited to.
|
||||
crate::items::enchant_lines(
|
||||
(0..7).map(|s| {
|
||||
(
|
||||
s,
|
||||
fields.item_enchant(s).unwrap_or(0),
|
||||
fields.item_enchant_charges(s),
|
||||
enchant_ms[usize::from(s)],
|
||||
)
|
||||
}),
|
||||
enchants,
|
||||
),
|
||||
),
|
||||
// The player descriptor references a guid whose create hasn't landed (yet) —
|
||||
// occupied, unresolved.
|
||||
None => return Some(ContainerSlot::default()),
|
||||
};
|
||||
if entry == 0 {
|
||||
return Some(ContainerSlot::default());
|
||||
}
|
||||
@@ -464,6 +486,7 @@ fn resolve_slot(
|
||||
readable,
|
||||
creator,
|
||||
flags,
|
||||
enchants: enchant_lines,
|
||||
..Default::default()
|
||||
});
|
||||
};
|
||||
@@ -480,6 +503,7 @@ fn resolve_slot(
|
||||
readable,
|
||||
creator,
|
||||
flags,
|
||||
enchants: enchant_lines,
|
||||
equip_slots: find_equip_slot(t.inventory_type),
|
||||
bar_placeable: t.placeable_on_action_bar(),
|
||||
cooldown: t.use_spell.and_then(|u| {
|
||||
@@ -496,6 +520,8 @@ pub(super) fn feed_containers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut items: ResMut<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// `SpellItemEnchantment`'s name column — the tooltip's enchant lines (decision 0915).
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
cooldowns: Res<crate::cooldowns::Cooldowns>,
|
||||
@@ -513,19 +539,33 @@ pub(super) fn feed_containers(
|
||||
let now = std::time::Instant::now();
|
||||
let ui_now = script.now();
|
||||
let spell_catalog = spells.as_deref().map(|s| &s.catalog);
|
||||
let enchant_rows = enchants.as_deref();
|
||||
// Inventory refusals surface as the client's red error line (the cast path's exact shape):
|
||||
// the wire code keys into the VM's own GlobalStrings ([`equip_error_key`]), reason 1 filling
|
||||
// its `%d` with the packet's required level. A code with no 1.12 string (59, past-the-enum)
|
||||
// — or a key missing from the VM, which would be OUR load bug — prints the hex debug line
|
||||
// rather than vanishing: these are server refusals the player must see.
|
||||
// the wire code keys into the VM's own GlobalStrings ([`equip_error_key`], total), reason 1
|
||||
// filling its `%d` with the packet's required level.
|
||||
//
|
||||
// **An unresolvable or empty key prints NOTHING** — this is the reference's own behaviour,
|
||||
// not a fallback: `CGGameUI::DisplayError` is called unconditionally and the sink's
|
||||
// `cmp byte [ecx],0` guard (`0x4945b4`) drops an empty string before it renders or sounds.
|
||||
// It is what silences reason 59, the lock-clear sentinel that rides alongside a real refusal
|
||||
// and used to print a second, hex-debug line over it (B198) — and it is the same law
|
||||
// `ui_action::cast_fail` already runs. No hex debug line on the player's screen: a code we
|
||||
// failed to map can't reach here (the table is total), and a key we typo'd is caught by
|
||||
// `equip_error`'s resolution test against the real `GlobalStrings.lua`, not at runtime.
|
||||
for (reason, level) in equip_errors.0.drain(..) {
|
||||
let text = equip_error_key(reason)
|
||||
.and_then(|key| script.lua().globals().get::<String>(key).ok())
|
||||
.map(|t| match level {
|
||||
Some(d) => t.replace("%d", &d.to_string()),
|
||||
None => t,
|
||||
})
|
||||
.unwrap_or_else(|| format!("Inventory error ({reason:#04x})"));
|
||||
let key = equip_error_key(reason);
|
||||
let text = script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.unwrap_or_default();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let text = match level {
|
||||
Some(d) => text.replace("%d", &d.to_string()),
|
||||
None => text,
|
||||
};
|
||||
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
|
||||
}
|
||||
for line in error_lines.0.drain(..) {
|
||||
@@ -557,6 +597,7 @@ pub(super) fn feed_containers(
|
||||
guid,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchant_rows,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
@@ -609,6 +650,7 @@ pub(super) fn feed_containers(
|
||||
guid,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchant_rows,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
@@ -641,6 +683,7 @@ pub(super) fn feed_containers(
|
||||
guid,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchant_rows,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
@@ -689,6 +732,7 @@ pub(super) fn feed_containers(
|
||||
guid,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchant_rows,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
@@ -725,6 +769,7 @@ pub(super) fn feed_containers(
|
||||
guid,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
enchant_rows,
|
||||
&commands,
|
||||
&cooldowns,
|
||||
spell_catalog,
|
||||
|
||||
+136
-226
@@ -449,6 +449,28 @@ pub(crate) fn has_key(store: &ObjectFields, items: &mut Items, commands: &NetCom
|
||||
})
|
||||
}
|
||||
|
||||
/// One resolved item-use click, as [`send_item_use`] needs it: the wire position, the two
|
||||
/// template scalars the fork reads, and the on-use SPELL the cast tail is keyed on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ItemUse {
|
||||
/// The live instance's guid (`None` = it didn't resolve; the fork falls through to a plain use).
|
||||
pub(crate) guid: Option<u64>,
|
||||
/// Template `StartQuest` — non-zero diverts to the quest offer, ahead of the cast tail.
|
||||
pub(crate) start_quest: u32,
|
||||
/// The wire position (`255` = the player array).
|
||||
pub(crate) bag_index: u8,
|
||||
/// The wire slot, 0-based.
|
||||
pub(crate) slot: u8,
|
||||
/// The template spell BLOCK ordinal the server should cast (decision 0666).
|
||||
pub(crate) spell_index: u8,
|
||||
/// The template's ON_USE spell id — `0x5d8c80`'s answer: the first template block whose
|
||||
/// `SpellId != 0` **and** `SpellTrigger == 0`. `None` = there is no such block.
|
||||
pub(crate) use_spell: Option<u32>,
|
||||
/// The GameObject this use is aimed at — the key-in-a-lock arm (decision 0769), which is
|
||||
/// `CGItem::Use`'s own target argument. `None` for every ordinary click.
|
||||
pub(crate) on_object: Option<u64>,
|
||||
}
|
||||
|
||||
/// **The item-use fork** — our `CGItem::Use` (`0x5d8d00`): the one place that decides what "using"
|
||||
/// an item actually sends. The reference has exactly one such function and every use surface calls
|
||||
/// it — the bag click (`Script::UseContainerItem` @ `0x4fa430`), the doll click
|
||||
@@ -468,104 +490,96 @@ pub(crate) fn has_key(store: &ObjectFields, items: &mut Items, commands: &NetCom
|
||||
///
|
||||
/// `guid: None` (the template/instance didn't resolve) falls through to the plain use, whose
|
||||
/// refusal is at least visible — the same fallback the equip fork makes.
|
||||
pub(crate) fn item_use_command(
|
||||
guid: Option<u64>,
|
||||
start_quest: u32,
|
||||
bag_index: u8,
|
||||
slot: u8,
|
||||
spell_index: u8,
|
||||
) -> crate::net::ClientCommand {
|
||||
match guid.filter(|_| start_quest != 0) {
|
||||
Some(item) => crate::net::ClientCommand::QuestgiverQuery {
|
||||
npc: item,
|
||||
quest: start_quest,
|
||||
},
|
||||
None => crate::net::ClientCommand::UseItem {
|
||||
bag_index,
|
||||
slot,
|
||||
// The template BLOCK ordinal the server should cast, not a flag (decision 0666); the
|
||||
// callers that hold the template name it, the doll drain sends 0.
|
||||
spell_index,
|
||||
// A bag/doll click uses the item on yourself; only the lock chain aims one at an
|
||||
// object (decision 0769 — `target::click`'s key arm).
|
||||
go_target: None,
|
||||
},
|
||||
///
|
||||
/// [`ItemUseRoute`] is that decision as a value — the pure fork, split from [`send_item_use`] so
|
||||
/// the law is testable without a World.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ItemUseRoute {
|
||||
/// Arm #3 (`0x5d8dd2`): a non-zero `StartQuest` offers its quest and **returns**, long before
|
||||
/// the cast tail at `0x5d9249`. An offer is not a cast.
|
||||
QuestOffer { npc: u64, quest: u32 },
|
||||
/// The cast tail (`0x5d9249`–`0x5d9258` → `0x6e5a90` → `TryCast`): run the whole ladder.
|
||||
Cast(u32),
|
||||
/// `0x5d8c80` found no ON_USE block, so it returns 0 and the tail hands `TryCast` spell id
|
||||
/// **0** — whose rec lookup `[0xc0d788 + 0]` is null and whose `6e4bac` bail returns false
|
||||
/// with **nothing sent**. (Before decision 0914 we sent anyway, and vmangos answered
|
||||
/// `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not found." the reference never shows.)
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// [`ItemUseRoute`]'s decision. `guid: None` (the instance never resolved) cannot address a
|
||||
/// questgiver, so it falls through to the ordinary path — the same fallback the equip fork makes.
|
||||
pub(crate) fn item_use_route(it: ItemUse) -> ItemUseRoute {
|
||||
if let Some(npc) = it.guid.filter(|_| it.start_quest != 0) {
|
||||
return ItemUseRoute::QuestOffer {
|
||||
npc,
|
||||
quest: it.start_quest,
|
||||
};
|
||||
}
|
||||
match it.use_spell {
|
||||
Some(spell) => ItemUseRoute::Cast(spell),
|
||||
None => ItemUseRoute::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// One resolved item-use click, as [`send_item_use`] needs it: the wire position, the two
|
||||
/// template scalars the fork reads, and the on-use SPELL the cast tail is keyed on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ItemUse {
|
||||
/// The live instance's guid (`None` = it didn't resolve; the fork falls through to a plain use).
|
||||
pub(crate) guid: Option<u64>,
|
||||
/// Template `StartQuest` — non-zero diverts to the quest offer, ahead of the cast tail.
|
||||
pub(crate) start_quest: u32,
|
||||
/// The wire position (`255` = the player array).
|
||||
pub(crate) bag_index: u8,
|
||||
/// The wire slot, 0-based.
|
||||
pub(crate) slot: u8,
|
||||
/// The template spell BLOCK ordinal the server should cast (decision 0666).
|
||||
pub(crate) spell_index: u8,
|
||||
/// The template's on-use SPELL id. `None` = this item casts nothing, so it never reaches the
|
||||
/// reference's cast tail and takes neither the gate nor the arm.
|
||||
pub(crate) use_spell: Option<u32>,
|
||||
}
|
||||
|
||||
/// **The item-use fork's cast tail** — [`item_use_command`] plus the one gate that makes an item
|
||||
/// use a *cast* rather than a bare packet, and the only place a `CMSG_USE_ITEM` leaves benilla.
|
||||
/// **The item-use fork's cast tail** — the only place a `CMSG_USE_ITEM` leaves benilla, and the
|
||||
/// seam where an item use enters the *one* cast ladder.
|
||||
///
|
||||
/// The reference has no separate item-send path at all. `CGItem::Use 0x5d8d00`'s tail reaches
|
||||
/// `0x5d9249`–`0x5d9258` and calls `0x6e5a90(item, spell, target)`, whose whole 54-byte body is
|
||||
/// `call 0x6e4b60` — **`TryCast`**, the same function the spellbook, `/cast` and the action bar's
|
||||
/// SPELL arm run (VERIFIED at the bytes in wow-re's `system/ui/scratch/disasm-full.txt`;
|
||||
/// independently recorded in its `action-button-state-api.md` §"dispatcher `0x6e5a90`
|
||||
/// (→ `CastSpell 0x6e4b60`)" and `cursor-system.md` §536). So an item use inherits TryCast's
|
||||
/// **IsCasting refusal** (`0x6e3d30` over the inflight id `0xceca88`, byte-verified in
|
||||
/// `wave-cast.md`): the same spell bails silently (`6e4d43`), a different one errors 0x61
|
||||
/// "Another action is in progress" — and never sends.
|
||||
/// The reference has no separate item-send path at all. `CGItem::Use 0x5d8d00`'s ordinary tail
|
||||
/// (`5d9249`–`5d9258`) calls `0x6e5a90`, whose whole 54-byte body is `call 0x6e4b60` —
|
||||
/// **`TryCast`**, the same function the spellbook, `/cast` and the action bar's SPELL arm run,
|
||||
/// with the **item as an ordinary argument** (`ret 0xc`). Inside TryCast that argument is read
|
||||
/// exactly twice, and neither read skips a gate: `6e4d76` computes a display flag ahead of the
|
||||
/// IsCasting check, and `6e4f33` forwards the item to the requirement validator `0x6094f0`. The
|
||||
/// commit `SendCast 0x6e54f0` then picks the opcode from it (`0x6e57d8 push 0xab`). So an item
|
||||
/// use takes the whole ladder — cooldown, GCD, in-flight, mounted, moving, form, reagents, target
|
||||
/// bind, range — and [`crate::ui_action::CastLadder::send`] is where all of that lives (decision
|
||||
/// 0914; verified at the bytes in wow-re's `system/ui/scratch/disasm-full.txt`, corroborated by
|
||||
/// its `action-item-slot.md` §8 and `cursor-system.md` §8.4a).
|
||||
///
|
||||
/// Ours did not, and that was decision 0908's bug (director's report B200): double-clicking a
|
||||
/// mount shipped a second `CMSG_USE_ITEM`, vmangos answered it `SPELL_FAILED_SPELL_IN_PROGRESS`
|
||||
/// (`Spell::prepare`'s `IsNonMeleeSpellCasted` gate), and the failure — spell-id-keyed like every
|
||||
/// reap, and naming the *same* spell as the running cast — red-faded the **running** cast's bar
|
||||
/// while the original cast completed anyway. The spell arm had had this rung since the identical
|
||||
/// spam-cancel bug ([`crate::ui_cast::PendingCast`]'s own doc); the item arm bypassed it because
|
||||
/// it bypassed the ladder. One guard resource, every cast source arms and checks it — the
|
||||
/// reference's one inflight id.
|
||||
/// Decision 0908 put only the in-flight rung here — the fix for the director's B200, where
|
||||
/// double-clicking a mount shipped a second `CMSG_USE_ITEM`, vmangos answered it
|
||||
/// `SPELL_FAILED_SPELL_IN_PROGRESS`, and the failure (spell-id-keyed, naming the *same* spell as
|
||||
/// the running cast) red-faded the running bar while the cast completed anyway. 0914 finishes the
|
||||
/// job: the rest of the ladder was still bypassed, so a bag click ignored cooldowns, a mounted or
|
||||
/// moving item click sent a doomed packet, and the bar's ITEM arm carried a private duplicate of
|
||||
/// the cooldown rung the bag and doll clicks never got.
|
||||
///
|
||||
/// Returns whether the use went out (`false` = refused by the gate), for the callers that log.
|
||||
/// Returns whether anything left for the server — `false` for the reference's silent no-op.
|
||||
pub(crate) fn send_item_use(
|
||||
it: ItemUse,
|
||||
pending: &mut crate::ui_cast::PendingCast,
|
||||
cast_errors: &mut crate::ui_action::CastErrors,
|
||||
commands: &crate::net::NetCommands,
|
||||
ctx: &crate::ui_action::cast_target::CastContext,
|
||||
ladder: &mut crate::ui_action::CastLadder,
|
||||
) -> bool {
|
||||
let command = item_use_command(
|
||||
it.guid,
|
||||
it.start_quest,
|
||||
it.bag_index,
|
||||
it.slot,
|
||||
it.spell_index,
|
||||
);
|
||||
// The quest offer returns long before the cast tail in the reference too (arm #3 at
|
||||
// `0x5d8dd2`, well ahead of `0x5d9249`) — an offer is not a cast, so no gate and no arm. Same
|
||||
// for an item with no on-use spell: it never reaches the tail either.
|
||||
let (crate::net::ClientCommand::UseItem { .. }, Some(spell)) = (&command, it.use_spell) else {
|
||||
let _ = commands.0.send(command);
|
||||
return true;
|
||||
};
|
||||
let now = std::time::Instant::now();
|
||||
if pending.in_flight(now) {
|
||||
if pending.current(now) != Some(spell) {
|
||||
cast_errors.0.push((spell, 0x61));
|
||||
match item_use_route(it) {
|
||||
ItemUseRoute::QuestOffer { npc, quest } => {
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(crate::net::ClientCommand::QuestgiverQuery { npc, quest });
|
||||
true
|
||||
}
|
||||
ItemUseRoute::Nothing => {
|
||||
debug!(
|
||||
"ui_items: the item at wire {}/{} has no ON_USE block — nothing sent (TryCast's null-rec bail)",
|
||||
it.bag_index, it.slot
|
||||
);
|
||||
false
|
||||
}
|
||||
ItemUseRoute::Cast(spell) => {
|
||||
ladder.send(
|
||||
spell,
|
||||
ctx,
|
||||
crate::ui_action::CastCommit::Item {
|
||||
bag_index: it.bag_index,
|
||||
slot: it.slot,
|
||||
spell_index: it.spell_index,
|
||||
on_object: it.on_object,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
debug!("ui_items: item use (spell {spell}) suppressed — a cast is already in flight");
|
||||
return false;
|
||||
}
|
||||
let _ = commands.0.send(command);
|
||||
pending.arm_item(spell, now);
|
||||
true
|
||||
}
|
||||
|
||||
/// The client's quality→color escape for an item link (`GetItemQualityColor`'s table) — shared by
|
||||
@@ -793,10 +807,9 @@ impl Plugin for UiItemsPlugin {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
find_equip_slot, item_use_command, keyring_size, send_item_use, wire_pos, ItemUse,
|
||||
find_equip_slot, item_use_route, keyring_size, wire_pos, ItemUse, ItemUseRoute,
|
||||
KEYRING_CONTAINER,
|
||||
};
|
||||
use crate::net::ClientCommand;
|
||||
use benilla_ui::script::EQUIPMENT_BAG;
|
||||
|
||||
/// The keyring's level ladder, both ends of every rung — the reference's `GetKeyRingSize`
|
||||
@@ -830,154 +843,51 @@ mod tests {
|
||||
assert_eq!(wire_pos(KEYRING_CONTAINER, 0), None);
|
||||
}
|
||||
|
||||
/// The use fork ([`item_use_command`], decision 0664): a non-zero `StartQuest` diverts to
|
||||
/// `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the ITEM's guid; everything else — including an
|
||||
/// item whose template never resolved — stays a plain `CMSG_USE_ITEM` on the wire position.
|
||||
/// The pure fork ([`item_use_route`], decisions 0664/0914), all three arms: a non-zero
|
||||
/// `StartQuest` diverts to `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the ITEM's guid (arm #3,
|
||||
/// `0x5d8dd2` — it returns before the cast tail); an ON_USE spell runs the ladder; **no** ON_USE
|
||||
/// block sends nothing at all, because `0x5d8c80` returns 0 and TryCast's null-rec bail
|
||||
/// (`6e4bac`) refuses spell id 0. That last arm is the one 0914 changed: we used to ship a
|
||||
/// `CMSG_USE_ITEM` vmangos answers `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not found." on
|
||||
/// every right-click of a plain trade good, which the reference never shows.
|
||||
#[test]
|
||||
fn item_use_forks_a_quest_starter_to_the_giver_query() {
|
||||
fn the_item_use_fork_routes_quest_offer_cast_and_nothing() {
|
||||
// "An Unsent Letter" (entry 2874, StartQuest 373 — live `mangos.item_template`): the item
|
||||
// guid is the questgiver, not the bag position.
|
||||
// guid is the questgiver, not the bag position. No quest-starter carries an on-use spell
|
||||
// (0 of the 215 live ones), but the quest arm wins even if one did.
|
||||
let letter = 0x4000_0000_0000_0BAD_u64;
|
||||
assert!(matches!(
|
||||
item_use_command(Some(letter), 373, 255, 23, 0),
|
||||
ClientCommand::QuestgiverQuery { npc, quest } if npc == letter && quest == 373
|
||||
));
|
||||
// A plain consumable (StartQuest 0) is the unchanged USE.
|
||||
assert!(matches!(
|
||||
item_use_command(Some(letter), 0, 255, 23, 1),
|
||||
ClientCommand::UseItem {
|
||||
bag_index: 255,
|
||||
slot: 23,
|
||||
// The template BLOCK ordinal rides through untouched (decision 0666).
|
||||
spell_index: 1,
|
||||
// A bag click never aims at an object (decision 0769).
|
||||
go_target: None
|
||||
}
|
||||
));
|
||||
// No resolved instance (template still in flight): the fallback is the USE whose refusal
|
||||
// is at least visible — never a query against guid 0.
|
||||
assert!(matches!(
|
||||
item_use_command(None, 373, 19, 4, 0),
|
||||
ClientCommand::UseItem {
|
||||
bag_index: 19,
|
||||
slot: 4,
|
||||
spell_index: 0,
|
||||
go_target: None
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// The cast tail's in-flight gate (decision 0908) — the fix for the director's B200: an item
|
||||
/// use is a cast through the same `TryCast` a spell press runs (`0x5d9258` → `0x6e5a90` →
|
||||
/// `0x6e4b60`), so a second one while the first is still in flight never reaches the wire.
|
||||
/// Before this, the duplicate `CMSG_USE_ITEM` drew `SPELL_FAILED_SPELL_IN_PROGRESS` naming the
|
||||
/// *running* cast's own spell, which red-faded its bar while the cast completed anyway.
|
||||
#[test]
|
||||
fn a_second_item_use_mid_cast_never_reaches_the_wire() {
|
||||
const HEARTHSTONE: u32 = 8690;
|
||||
const MOUNT: u32 = 470;
|
||||
let hearth = |use_spell| ItemUse {
|
||||
guid: Some(0x4000_0000_0000_0001),
|
||||
start_quest: 0,
|
||||
let it = |guid, start_quest, use_spell| ItemUse {
|
||||
guid,
|
||||
start_quest,
|
||||
bag_index: 255,
|
||||
slot: 24,
|
||||
slot: 23,
|
||||
spell_index: 0,
|
||||
use_spell,
|
||||
on_object: None,
|
||||
};
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let net = crate::net::NetCommands(tx);
|
||||
let mut pending = crate::ui_cast::PendingCast::default();
|
||||
let mut errors = crate::ui_action::CastErrors::default();
|
||||
|
||||
assert!(send_item_use(
|
||||
hearth(Some(HEARTHSTONE)),
|
||||
&mut pending,
|
||||
&mut errors,
|
||||
&net
|
||||
));
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::UseItem { .. })),
|
||||
"the first click goes out and arms the guard"
|
||||
);
|
||||
|
||||
// The double-click: the same spell bails SILENTLY — the ref's `6e4d43` same-spell leg.
|
||||
assert!(!send_item_use(
|
||||
hearth(Some(HEARTHSTONE)),
|
||||
&mut pending,
|
||||
&mut errors,
|
||||
&net
|
||||
));
|
||||
assert!(rx.try_recv().is_err(), "no duplicate on the wire");
|
||||
assert!(
|
||||
errors.0.is_empty(),
|
||||
"the same spell's re-press is silent, not a red line"
|
||||
);
|
||||
|
||||
// A *different* item mid-cast is the ref's `6e4d97` leg: reason 0x61, still no packet.
|
||||
assert!(!send_item_use(
|
||||
hearth(Some(MOUNT)),
|
||||
&mut pending,
|
||||
&mut errors,
|
||||
&net
|
||||
));
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
errors.0,
|
||||
vec![(MOUNT, 0x61)],
|
||||
"\"Another action is in progress\""
|
||||
item_use_route(it(Some(letter), 373, None)),
|
||||
ItemUseRoute::QuestOffer {
|
||||
npc: letter,
|
||||
quest: 373
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// What the gate must NOT hold: the arms that return before the reference's cast tail. A
|
||||
/// quest-starter offers its quest (arm #3, `0x5d8dd2`) and an item with no on-use spell never
|
||||
/// reaches `0x5d9249` at all — neither is a cast, so neither is gated or arms the guard.
|
||||
#[test]
|
||||
fn the_gate_spares_the_arms_that_are_not_casts() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let net = crate::net::NetCommands(tx);
|
||||
let mut pending = crate::ui_cast::PendingCast::default();
|
||||
let mut errors = crate::ui_action::CastErrors::default();
|
||||
let letter = 0x4000_0000_0000_0BAD_u64;
|
||||
|
||||
// Arm the guard as if a hearthstone were casting.
|
||||
pending.arm_item(8690, std::time::Instant::now());
|
||||
|
||||
assert!(send_item_use(
|
||||
ItemUse {
|
||||
guid: Some(letter),
|
||||
start_quest: 373,
|
||||
bag_index: 255,
|
||||
slot: 23,
|
||||
spell_index: 0,
|
||||
use_spell: None
|
||||
},
|
||||
&mut pending,
|
||||
&mut errors,
|
||||
&net,
|
||||
));
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::QuestgiverQuery { .. })),
|
||||
"an offer is not a cast — it goes out mid-cast"
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(letter), 0, Some(8690))),
|
||||
ItemUseRoute::Cast(8690),
|
||||
"a hearthstone takes the cast tail"
|
||||
);
|
||||
|
||||
assert!(send_item_use(
|
||||
ItemUse {
|
||||
guid: Some(letter),
|
||||
start_quest: 0,
|
||||
bag_index: 19,
|
||||
slot: 4,
|
||||
spell_index: 0,
|
||||
use_spell: None
|
||||
},
|
||||
&mut pending,
|
||||
&mut errors,
|
||||
&net,
|
||||
));
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(ClientCommand::UseItem { .. })),
|
||||
"a spell-less item casts nothing, so the in-flight cast cannot block it"
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(letter), 0, None)),
|
||||
ItemUseRoute::Nothing,
|
||||
"no ON_USE block — the ref sends nothing"
|
||||
);
|
||||
// No resolved instance (the template is still in flight): a query against guid 0 is
|
||||
// impossible, so the ordinary path runs — the equip fork's own fallback.
|
||||
assert_eq!(
|
||||
item_use_route(it(None, 373, Some(8690))),
|
||||
ItemUseRoute::Cast(8690)
|
||||
);
|
||||
assert!(errors.0.is_empty());
|
||||
}
|
||||
|
||||
/// A dozen representative `InventoryType`s (decision 0208 phase 1b's own ask), spanning the
|
||||
|
||||
@@ -22,21 +22,28 @@
|
||||
//! window when the player leaves the mailbox's interaction range (wow-re §5: the client's generic
|
||||
//! interaction-target range gate — past it every mail `CMSG` silently fails its `CheckMailBox`).
|
||||
//!
|
||||
//! **The arrival layer ([`MailPending`], decisions 0544 P3 / 0904, wow-re §5 `mail-interaction.md`
|
||||
//! §4).** `HasNewMail()` mirrors the real client's own model: a countdown float (`0x845eac`) fed by
|
||||
//! `MSG_QUERY_NEXT_MAIL_TIME`'s reply and `SMSG_RECEIVED_MAIL`, stepped down per frame, and read as
|
||||
//! **`|countdown| < ε`** — *near zero*, not `<= 0` ([`MailPending::has_new_mail`]; the sign matters,
|
||||
//! decision 0904: vmangos's "no mail" reply is `-86400.0`, which a `<= 0` predicate reads as "you
|
||||
//! have mail" at every login). vmangos only ever sends `0.0` ("waiting now") or `-86400.0` ("none")
|
||||
//! — never a positive value — but the countdown is implemented in full since that's the client's
|
||||
//! real model, not a `0.0`/`-86400.0` special case. [`send_query_next_mail_time_on_enter`] fires the
|
||||
//! query once per world-enter (0548 §7: "once at UI/login load"); [`feed_mail`] steps the
|
||||
//! float every frame ([`MailPending::step`]), pushes `HasNewMail()` to the VM
|
||||
//! ([`benilla_ui::script::UiScript::set_has_new_mail`]) and fires `UPDATE_PENDING_MAIL` on every
|
||||
//! value transition — the only event the reference `MiniMapMailFrame` listens for
|
||||
//! (`Minimap.xml` l.278-289: `OnEvent` just re-checks `HasNewMail()` and shows/hides; no
|
||||
//! `MAIL_INBOX_UPDATE`/`MAIL_CLOSED` registration exists in the reference despite the icon
|
||||
//! logically depending on the inbox state).
|
||||
//! **The arrival layer ([`MailPending`], decisions 0544 P3 / 0904 / 0913, wow-re
|
||||
//! `ui/scratch/mail-pending-countdown.md`).** `HasNewMail()` mirrors the real client's own model: a
|
||||
//! countdown float (`0x845eac`) read as **`|countdown| < ε`** — *near zero*, not `<= 0` (the sign
|
||||
//! matters, decision 0904: vmangos's "no mail" reply is `-86400.0`, which a `<= 0` predicate reads
|
||||
//! as "you have mail" at every login). vmangos only ever sends `0.0` ("waiting now") or `-86400.0`
|
||||
//! ("none") — never a positive value — but the countdown is implemented in full since that's the
|
||||
//! client's real model, not a two-value special case.
|
||||
//!
|
||||
//! **The icon's whole life is that float**, because `MiniMapMailFrame` is the *only* listener of
|
||||
//! `UPDATE_PENDING_MAIL` in all of 1.12 FrameXML and its handler is a bare idempotent
|
||||
//! `if HasNewMail() then Show() else Hide()` (`Minimap.xml` l.278-289) — it never listens for
|
||||
//! `MAIL_INBOX_UPDATE`/`MAIL_CLOSED` despite the icon logically depending on the inbox state. So
|
||||
//! **checking your mail clears the icon by a route that never touches the inbox** (decision 0913):
|
||||
//! opening a letter arms the deferred-refresh flag ([`MailPending::arm_refresh`], the reference's
|
||||
//! `[0xb6efcc]`), and the mailbox *close* re-asks the server — the sender stamping the countdown to
|
||||
//! [`MAIL_TIME_NO_MAIL`] and the reply settling it. The same stamp-then-ask runs at every
|
||||
//! world-enter ([`send_query_next_mail_time_on_enter`]), which is why the reference icon also blinks
|
||||
//! off across a loading screen.
|
||||
//!
|
||||
//! [`feed_mail`] steps the float every frame ([`MailPending::step`]), keeps
|
||||
//! [`benilla_ui::script::UiScript::set_has_new_mail`] current, and fires `UPDATE_PENDING_MAIL` at
|
||||
//! the three sites the reference fires it (see [`MailPending::notify`]).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -52,10 +59,12 @@ use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, S
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
|
||||
|
||||
/// `checked` mask bits (VERIFIED vmangos `Mail.h`, decision 0544): READ, RETURNED, COPIED. `READ`
|
||||
/// is `pub(crate)` — [`crate::net::apply::mail::mail_list`] reuses it to clear [`MailPending`]
|
||||
/// when a fresh list arrives with nothing left unread (decision 0544 P3).
|
||||
pub(crate) const CHECKED_READ: u32 = 0x1;
|
||||
mod pending;
|
||||
|
||||
pub(crate) use pending::MailPending;
|
||||
|
||||
/// `checked` mask bits (VERIFIED vmangos `Mail.h`, decision 0544): READ, RETURNED, COPIED.
|
||||
const CHECKED_READ: u32 = 0x1;
|
||||
const CHECKED_RETURNED: u32 = 0x2;
|
||||
const CHECKED_COPIED: u32 = 0x4;
|
||||
|
||||
@@ -186,48 +195,6 @@ impl NpcSession for MailOpen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The login-scoped unread-mail model (decision 0544 P3): the client's own countdown, in seconds
|
||||
/// until the next mail is "waiting" — `None` = never queried yet (`HasNewMail()` reads false, the
|
||||
/// reference's pre-first-query state). Independent of [`MailOpen`] (that's session-scoped to one
|
||||
/// open window; this survives it closing) — a separate resource is the cleaner split than fields
|
||||
/// bolted onto `MailOpen`. Filled by [`crate::net::apply::mail::next_mail_time`]/
|
||||
/// [`crate::net::apply::mail::received_mail`]; decremented and read by [`feed_mail`].
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MailPending(pub(crate) Option<f32>);
|
||||
|
||||
/// The `HasNewMail()` / countdown-step epsilon — the real client's `[0x8029d4]` = `2^-22`, the
|
||||
/// `fabs(x)`-vs-this threshold both `0x4afea0` (`HasNewMail`) and `0x4ade60` (the per-frame step)
|
||||
/// compare against (VERIFIED byte-exact in wow-re, `crates/ui/src/glue_geom_4a8.rs::EPS_BITS`).
|
||||
const MAIL_TIME_EPSILON: f32 = f32::from_bits(0x3480_0000); // 2^-22, wow-re's `EPS_BITS`
|
||||
|
||||
impl MailPending {
|
||||
/// `HasNewMail()` (decisions 0544 P3 / 0904; wow-re §5 §4, `0x4afea0` byte-exact:
|
||||
/// `fld [0x845eac]; fabs; fcomp [0x8029d4]`) — true iff the countdown is **within ε of zero**.
|
||||
///
|
||||
/// Near-zero, *not* `<= 0`: the sign carries meaning. vmangos answers
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME` with `-86400.0` when nothing is unread (`MailHandler.cpp`
|
||||
/// `HandleQueryNextMailTime`: `HasUnreadMail() ? 0.0f : -float(DAY)`), so a `<= 0` predicate
|
||||
/// lights the minimap icon on every login for a character with no mail (decision 0904).
|
||||
fn has_new_mail(&self) -> bool {
|
||||
self.0.is_some_and(|s| s.abs() < MAIL_TIME_EPSILON)
|
||||
}
|
||||
|
||||
/// The per-frame countdown step (wow-re §5 §4, `0x4ade60` byte-exact — carved as
|
||||
/// `glue_geom_4a8::step_value`): a **non-positive countdown is left alone** (the `-86400.0`
|
||||
/// "no mail" sentinel must never drift toward zero, and a reached-zero countdown must not sail
|
||||
/// past it into negative — either would flip [`Self::has_new_mail`] the wrong way), and a
|
||||
/// positive one steps down floor-clamped at `0.0`. The subtraction runs the client's x87 chain
|
||||
/// (f64 under PC_53, narrowed at the store).
|
||||
fn step(&mut self, delta_secs: f32) {
|
||||
if let Some(cur) = self.0.as_mut() {
|
||||
if *cur > 0.0 {
|
||||
let diff = f64::from(*cur) - f64::from(delta_secs); // fsub, then fcom vs 0.0
|
||||
*cur = if diff > 0.0 { diff as f32 } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiMailPlugin;
|
||||
|
||||
impl Plugin for UiMailPlugin {
|
||||
@@ -253,14 +220,21 @@ impl Plugin for UiMailPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME`, sent once per world-enter (decision 0544 P3/0548 §7 — "the 0x284
|
||||
/// query goes out once at UI/login load... not on a recurring timer") to seed [`MailPending`]/
|
||||
/// `HasNewMail()` for a character that already had mail waiting at login.
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME`, sent once per **world-enter** — VERIFIED faithful (decision 0913):
|
||||
/// the query's two callers are the mail module's init tail and the close-with-pending path, and
|
||||
/// that init hangs off the world-enter cascade `0x4908c0` (`PLAYER_ENTERING_WORLD` + 37 subsystem
|
||||
/// inits), whose guard is re-armed by the paired teardown — so it runs per world-enter (login,
|
||||
/// worldport, instance transfer, `ReloadUI`), not once per process as an earlier note had it.
|
||||
///
|
||||
/// The init stamps the countdown to [`MAIL_TIME_NO_MAIL`] before asking, which is why the reference
|
||||
/// icon blinks off across a loading screen and comes back with the reply.
|
||||
fn send_query_next_mail_time_on_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
commands: Res<NetCommands>,
|
||||
mut pending: ResMut<MailPending>,
|
||||
) {
|
||||
if entered.read().next().is_some() {
|
||||
pending.on_query_sent();
|
||||
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
|
||||
}
|
||||
}
|
||||
@@ -479,6 +453,16 @@ fn feed_mail(
|
||||
script.fire_event("MAIL_SEND_SUCCESS", vec![]);
|
||||
} else if closed {
|
||||
script.fire_event("MAIL_CLOSED", vec![]);
|
||||
// The close core's tail (wow-re `0x4acdad`/`0x4acdb1`, decision 0913): a session where a
|
||||
// mail was read — or one arrived while the window was open — re-asks the server, and the
|
||||
// sender stamps the countdown to "no mail" on the way out. This is the whole mechanism by
|
||||
// which checking your mail clears the minimap icon; nothing on the inbox path touches it.
|
||||
// Both close paths converge here (explicit `CloseMail` and the walk-away range close), as
|
||||
// they do in the reference through the same generic dispatcher.
|
||||
if pending.take_refresh() {
|
||||
pending.on_query_sent();
|
||||
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
|
||||
}
|
||||
} else if mail.mailbox.is_some() {
|
||||
// A re-click re-shows the (already open) window; MAIL_SHOW's CheckInbox refreshes the list.
|
||||
if show_requested {
|
||||
@@ -492,16 +476,23 @@ fn feed_mail(
|
||||
*last = fresh;
|
||||
*last_mailbox = mail.mailbox;
|
||||
|
||||
// The arrival layer (decisions 0544 P3 / 0904): step the client-local countdown, then push/fire
|
||||
// on every `HasNewMail()` value transition — the reference `MiniMapMailFrame`'s only listened
|
||||
// event (`Minimap.xml` l.278-289, quoted in the module doc).
|
||||
// The arrival layer (decisions 0544 P3 / 0904 / 0913): step the client-local countdown, keep
|
||||
// the VM's `HasNewMail()` answer current, and fire `UPDATE_PENDING_MAIL` only where the
|
||||
// reference fires it. The two are deliberately separate: the *push* keeps the Lua getter
|
||||
// truthful whenever it is asked, while the *event* is edge-triggered at three sites only (the
|
||||
// query reply, a near-zero arrival, the step crossing ε) — so, exactly as in the reference, the
|
||||
// query sender's "no mail" stamp does not move the icon until the reply lands.
|
||||
// `MiniMapMailFrame` is the sole listener in all of 1.12 FrameXML and its handler is an
|
||||
// idempotent Show/Hide (`Minimap.xml` l.278-289, quoted in the module doc).
|
||||
pending.step(time.delta_secs());
|
||||
let has_new_mail = pending.has_new_mail();
|
||||
if has_new_mail != *last_has_new_mail {
|
||||
script.set_has_new_mail(has_new_mail);
|
||||
script.fire_event("UPDATE_PENDING_MAIL", vec![]);
|
||||
*last_has_new_mail = has_new_mail;
|
||||
}
|
||||
if pending.take_notify() {
|
||||
script.fire_event("UPDATE_PENDING_MAIL", vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the Lua intents into the mail `CMSG`s (decision 0544): `CheckInbox` → a throttled
|
||||
@@ -516,6 +507,7 @@ fn drain_mail(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
items: Res<Items>,
|
||||
time: Res<Time>,
|
||||
mut pending: ResMut<MailPending>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -552,6 +544,13 @@ fn drain_mail(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Arm the deferred refresh on **every** open, read or not: the reference's `GetInboxText`
|
||||
// calls the mark-as-read sender unconditionally, and the `[0xb6efcc] = 1` inside it is
|
||||
// unconditional too (`0x4adda6`) — so opening any letter is what makes the close re-ask the
|
||||
// server, and that is what clears the minimap icon (decision 0913). Arming it on an
|
||||
// already-read mail costs at most one redundant query on close; failing to arm it would
|
||||
// strand the icon lit, so the eager side is the safe side.
|
||||
pending.arm_refresh();
|
||||
if !read {
|
||||
mail.mark_read(index);
|
||||
let _ = commands
|
||||
@@ -829,56 +828,4 @@ mod tests {
|
||||
// The item name/texture are in flight (no template answer yet).
|
||||
assert!(row.item_name.is_none());
|
||||
}
|
||||
|
||||
/// `HasNewMail()` is `|countdown| < ε`, not `countdown <= 0` (decision 0904). The regression
|
||||
/// this pins: vmangos's "no unread mail" answer is `-86400.0`, and the old `<= 0` predicate
|
||||
/// read it as "you have mail" — the phantom minimap icon on every login.
|
||||
#[test]
|
||||
fn has_new_mail_is_near_zero_not_non_positive() {
|
||||
// Never queried — the reference's pre-first-query state.
|
||||
assert!(!MailPending(None).has_new_mail());
|
||||
// vmangos's two real answers.
|
||||
assert!(MailPending(Some(0.0)).has_new_mail());
|
||||
assert!(!MailPending(Some(-86400.0)).has_new_mail());
|
||||
// The threshold itself: inside ε counts, ε and beyond does not — either sign.
|
||||
assert!(MailPending(Some(MAIL_TIME_EPSILON / 2.0)).has_new_mail());
|
||||
assert!(MailPending(Some(-MAIL_TIME_EPSILON / 2.0)).has_new_mail());
|
||||
assert!(!MailPending(Some(MAIL_TIME_EPSILON)).has_new_mail());
|
||||
assert!(!MailPending(Some(-MAIL_TIME_EPSILON)).has_new_mail());
|
||||
// A still-running countdown is not "waiting now".
|
||||
assert!(!MailPending(Some(30.0)).has_new_mail());
|
||||
}
|
||||
|
||||
/// The per-frame step (`0x4ade60`): non-positive is untouched, positive steps down and floors
|
||||
/// at exactly `0.0` — so a countdown that expires flips `HasNewMail()` true and *stays* true.
|
||||
#[test]
|
||||
fn step_leaves_non_positive_alone_and_floors_at_zero() {
|
||||
// The "no mail" sentinel never drifts toward zero, however long the session runs.
|
||||
let mut none_waiting = MailPending(Some(-86400.0));
|
||||
for _ in 0..100 {
|
||||
none_waiting.step(1.0 / 60.0);
|
||||
}
|
||||
assert_eq!(none_waiting.0, Some(-86400.0));
|
||||
assert!(!none_waiting.has_new_mail());
|
||||
|
||||
// A reached-zero countdown stays at zero rather than sailing past into negative.
|
||||
let mut expired = MailPending(Some(0.0));
|
||||
expired.step(1.0 / 60.0);
|
||||
assert_eq!(expired.0, Some(0.0));
|
||||
assert!(expired.has_new_mail());
|
||||
|
||||
// A positive countdown steps down, then floors (never overshoots past 0).
|
||||
let mut counting = MailPending(Some(0.5));
|
||||
counting.step(0.125); // exact in binary — the step's value is pinned, not approximated
|
||||
assert_eq!(counting.0, Some(0.375));
|
||||
assert!(!counting.has_new_mail());
|
||||
counting.step(10.0);
|
||||
assert_eq!(counting.0, Some(0.0));
|
||||
assert!(counting.has_new_mail());
|
||||
|
||||
// Never queried stays never-queried.
|
||||
let mut unqueried = MailPending(None);
|
||||
unqueried.step(1.0);
|
||||
assert_eq!(unqueried.0, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! The **arrival layer** of the mail arc (decisions 0544 P3 / 0904 / 0913) — the real client's
|
||||
//! pending-mail countdown, modelled field for field.
|
||||
//!
|
||||
//! This is the half of [`super`] that has nothing to do with the mailbox *window*: it survives the
|
||||
//! window closing, it is what `HasNewMail()` answers from, and it is the entire life of the minimap
|
||||
//! mail icon. The window session lives next door in [`super`]; the seam between them is narrow on
|
||||
//! purpose — the two only meet at the mailbox close edge, where a read letter turns into the
|
||||
//! re-query that clears the icon.
|
||||
//!
|
||||
//! Every rule here is byte-VERIFIED in wow-re (`ui/scratch/mail-pending-countdown.md`), which is
|
||||
//! also where the load-bearing *negative* lives: **nothing on the inbox path writes the countdown**.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The `HasNewMail()` / countdown-step epsilon — the real client's `[0x8029d4]` = `2^-22`, the
|
||||
/// `fabs(x)`-vs-this threshold both `0x4afea0` (`HasNewMail`) and `0x4ade60` (the per-frame step)
|
||||
/// compare against (VERIFIED byte-exact in wow-re, `crates/ui/src/glue_geom_4a8.rs::EPS_BITS`).
|
||||
const MAIL_TIME_EPSILON: f32 = f32::from_bits(0x3480_0000); // 2^-22, wow-re's `EPS_BITS`
|
||||
|
||||
/// The "no mail waiting" stamp: the literal **`-1.0f`** the mail module's init (`0x4acb87`) and the
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME` **sender** (`0x4ade25`) both write into the countdown,
|
||||
/// unconditionally (VERIFIED wow-re, decision 0913). Any value outside ε of zero reads "no mail";
|
||||
/// `-1.0` is simply the one the reference picks, and it is what the countdown holds between asking
|
||||
/// the server and hearing back.
|
||||
const MAIL_TIME_NO_MAIL: f32 = -1.0;
|
||||
|
||||
/// The client's own pending-mail model (decisions 0544 P3 / 0904 / 0913) — the countdown float
|
||||
/// `0x845eac` plus the deferred-refresh flag `[0xb6efcc]`, mirrored field for field.
|
||||
///
|
||||
/// **Why a countdown and not a bool.** `HasNewMail()` is `|countdown| < ε`; the countdown is
|
||||
/// seeded by `MSG_QUERY_NEXT_MAIL_TIME`'s reply and `SMSG_RECEIVED_MAIL`, stepped down per frame,
|
||||
/// and stamped back to [`MAIL_TIME_NO_MAIL`] whenever the client (re-)asks the server. Independent
|
||||
/// of [`MailOpen`] (that is session-scoped to one open window; this survives it closing).
|
||||
///
|
||||
/// **The five writers**, all VERIFIED at the bytes (wow-re `mail-pending-countdown.md`; the
|
||||
/// earlier §4 note listed only three, which is what made the clear-on-inbox-read guess look
|
||||
/// plausible — decision 0913): the module init and the query sender stamp `-1.0`
|
||||
/// ([`Self::on_query_sent`]), the query reply stores the server's float
|
||||
/// ([`Self::apply_query_reply`]), `SMSG_RECEIVED_MAIL` runs the set-value ladder
|
||||
/// ([`Self::apply_received_mail`]), and the per-frame step counts down ([`Self::step`]).
|
||||
/// **Nothing on the inbox path writes it** — that negative is the load-bearing half.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct MailPending {
|
||||
/// The countdown float `0x845eac`, in seconds until mail is "waiting".
|
||||
countdown: f32,
|
||||
/// The deferred-refresh flag `[0xb6efcc]`: "re-ask the server on close". Set by the
|
||||
/// mark-as-read sender (`0x4adda6`) and by `SMSG_RECEIVED_MAIL` arriving while the mailbox is
|
||||
/// open (`0x4ad642`); read at exactly one site — the close core (`0x4acda8`).
|
||||
refresh_pending: bool,
|
||||
/// A queued `UPDATE_PENDING_MAIL`, drained by [`feed_mail`]. The event is **edge-triggered at
|
||||
/// three sites only** (`0x4ad605` the reply, `0x4ad66b` the near-zero arrival, `0x4adeba` the
|
||||
/// step crossing under ε) — not per frame, and not on every value change.
|
||||
notify: bool,
|
||||
}
|
||||
|
||||
impl Default for MailPending {
|
||||
/// The reference's own resting state: no mail waiting, nothing deferred. (The module init
|
||||
/// stamps exactly this at every world-enter, so it is also the post-`PLAYER_ENTERING_WORLD`
|
||||
/// value before the reply lands.)
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
countdown: MAIL_TIME_NO_MAIL,
|
||||
refresh_pending: false,
|
||||
notify: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MailPending {
|
||||
/// `HasNewMail()` (wow-re `0x4afea0` byte-exact: `fld [0x845eac]; fabs; fcomp [0x8029d4]`,
|
||||
/// emitted `jp`) — true iff the countdown is **within ε of zero**. Strict `<`, symmetric, and
|
||||
/// false at exact equality and for NaN.
|
||||
///
|
||||
/// Near-zero, *not* `<= 0`: the sign carries meaning. vmangos answers
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME` with `-86400.0` when nothing is unread (`MailHandler.cpp`
|
||||
/// `HandleQueryNextMailTime`: `HasUnreadMail() ? 0.0f : -float(DAY)`), so a `<= 0` predicate
|
||||
/// lights the minimap icon on every login for a character with no mail (decision 0904).
|
||||
pub(super) fn has_new_mail(&self) -> bool {
|
||||
self.countdown.abs() < MAIL_TIME_EPSILON
|
||||
}
|
||||
|
||||
/// The per-frame countdown step (wow-re `0x4ade60` byte-exact — carved as
|
||||
/// `glue_geom_4a8::step_value`): a **non-positive countdown is left alone** (the "no mail"
|
||||
/// stamp must never drift toward zero, and a reached-zero countdown must not sail past it into
|
||||
/// negative — either would flip [`Self::has_new_mail`] the wrong way), and a positive one steps
|
||||
/// down floor-clamped at `0.0`. The subtraction runs the client's x87 chain (f64 under PC_53,
|
||||
/// narrowed at the store). Signals once, on the step that lands inside ε.
|
||||
pub(super) fn step(&mut self, delta_secs: f32) {
|
||||
if self.countdown > 0.0 {
|
||||
let diff = f64::from(self.countdown) - f64::from(delta_secs); // fsub, then fcom vs 0.0
|
||||
self.countdown = if diff > 0.0 { diff as f32 } else { 0.0 };
|
||||
if self.has_new_mail() {
|
||||
self.notify = true; // 0x4adeba — the crossing edge, fired once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `MSG_QUERY_NEXT_MAIL_TIME` **reply** (wow-re `0x4ad5f0`): store the server's float
|
||||
/// verbatim and signal — **unconditionally**, whether or not `HasNewMail()` changed.
|
||||
pub(crate) fn apply_query_reply(&mut self, seconds: f32) {
|
||||
self.countdown = seconds;
|
||||
self.notify = true; // 0x4ad605 — unconditional
|
||||
}
|
||||
|
||||
/// Sending `MSG_QUERY_NEXT_MAIL_TIME` (wow-re: the sender `0x4ade25` and the module init
|
||||
/// `0x4acb87` both stamp `-1.0f`): the countdown reads "no mail" from the moment we ask until
|
||||
/// the reply lands. **No signal** — the sender does not fire `UPDATE_PENDING_MAIL`, so the icon
|
||||
/// keeps its old face for the round trip and updates when the reply arrives.
|
||||
pub(super) fn on_query_sent(&mut self) {
|
||||
self.countdown = MAIL_TIME_NO_MAIL;
|
||||
}
|
||||
|
||||
/// `SMSG_RECEIVED_MAIL` (wow-re `0x4ad620` byte-exact — carved as `glue_geom_4a8::set_value`).
|
||||
/// `mailbox_open` is the busy-flag pair `[0xb6ef88]|[0xb6ef8c]`, the open mailbox's guid:
|
||||
///
|
||||
/// * **busy** → arm the deferred refresh and leave the countdown alone (the icon does not move
|
||||
/// while you stand at the mailbox; the close re-query settles it);
|
||||
/// * `|delay| < ε` → store it and signal ("you have mail *now*");
|
||||
/// * else if the current countdown is negative → store it (no signal);
|
||||
/// * else store the *smaller* of the two (tightening the estimate), no signal.
|
||||
pub(crate) fn apply_received_mail(&mut self, seconds: f32, mailbox_open: bool) {
|
||||
if mailbox_open {
|
||||
self.refresh_pending = true; // 0x4ad642
|
||||
return;
|
||||
}
|
||||
if f64::from(seconds).abs() < f64::from(MAIL_TIME_EPSILON) {
|
||||
self.countdown = seconds;
|
||||
self.notify = true; // 0x4ad66b
|
||||
} else if self.countdown < 0.0 || seconds < self.countdown {
|
||||
self.countdown = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the deferred refresh `[0xb6efcc]` — the mark-as-read sender does this unconditionally
|
||||
/// (`0x4adda6`), which is *the* mechanism by which checking your mail clears the icon: the
|
||||
/// close core sees the flag and re-asks the server (decision 0913).
|
||||
pub(crate) fn arm_refresh(&mut self) {
|
||||
self.refresh_pending = true;
|
||||
}
|
||||
|
||||
/// The close core's read of `[0xb6efcc]` (`0x4acda8`) — the flag's only reader, consumed.
|
||||
pub(super) fn take_refresh(&mut self) -> bool {
|
||||
std::mem::take(&mut self.refresh_pending)
|
||||
}
|
||||
|
||||
/// Drain a queued `UPDATE_PENDING_MAIL`.
|
||||
pub(super) fn take_notify(&mut self) -> bool {
|
||||
std::mem::take(&mut self.notify)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A [`MailPending`] holding `countdown`, as if the server had just answered with it.
|
||||
fn pending_at(countdown: f32) -> MailPending {
|
||||
MailPending {
|
||||
countdown,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `HasNewMail()` is `|countdown| < ε`, not `countdown <= 0` (decision 0904). The regression
|
||||
/// this pins: vmangos's "no unread mail" answer is `-86400.0`, and the old `<= 0` predicate
|
||||
/// read it as "you have mail" — the phantom minimap icon on every login.
|
||||
#[test]
|
||||
fn has_new_mail_is_near_zero_not_non_positive() {
|
||||
// The resting state — the reference's own `-1.0` init stamp.
|
||||
assert!(!MailPending::default().has_new_mail());
|
||||
// vmangos's two real answers.
|
||||
assert!(pending_at(0.0).has_new_mail());
|
||||
assert!(!pending_at(-86400.0).has_new_mail());
|
||||
// The threshold itself: inside ε counts, ε and beyond does not — either sign. Strict `<`,
|
||||
// so exact equality is false (wow-re: the emitted `jp` over the x87 C-bits).
|
||||
assert!(pending_at(MAIL_TIME_EPSILON / 2.0).has_new_mail());
|
||||
assert!(pending_at(-MAIL_TIME_EPSILON / 2.0).has_new_mail());
|
||||
assert!(!pending_at(MAIL_TIME_EPSILON).has_new_mail());
|
||||
assert!(!pending_at(-MAIL_TIME_EPSILON).has_new_mail());
|
||||
// Unordered compares false, as the x87 predicate does.
|
||||
assert!(!pending_at(f32::NAN).has_new_mail());
|
||||
// A still-running countdown is not "waiting now".
|
||||
assert!(!pending_at(30.0).has_new_mail());
|
||||
}
|
||||
|
||||
/// The per-frame step (`0x4ade60`): non-positive is untouched, positive steps down and floors
|
||||
/// at exactly `0.0` — so a countdown that expires flips `HasNewMail()` true and *stays* true,
|
||||
/// signalling exactly once on the crossing.
|
||||
#[test]
|
||||
fn step_leaves_non_positive_alone_and_floors_at_zero() {
|
||||
// The "no mail" stamp never drifts toward zero, however long the session runs.
|
||||
let mut none_waiting = pending_at(-86400.0);
|
||||
for _ in 0..100 {
|
||||
none_waiting.step(1.0 / 60.0);
|
||||
}
|
||||
assert_eq!(none_waiting.countdown, -86400.0);
|
||||
assert!(!none_waiting.has_new_mail());
|
||||
assert!(
|
||||
!none_waiting.take_notify(),
|
||||
"a dormant countdown never signals"
|
||||
);
|
||||
|
||||
// A reached-zero countdown stays at zero rather than sailing past into negative — and does
|
||||
// not re-signal every frame (the reference's edge is one-shot).
|
||||
let mut expired = pending_at(0.0);
|
||||
expired.step(1.0 / 60.0);
|
||||
assert_eq!(expired.countdown, 0.0);
|
||||
assert!(expired.has_new_mail());
|
||||
assert!(!expired.take_notify());
|
||||
|
||||
// A positive countdown steps down, then floors (never overshoots past 0), signalling on the
|
||||
// step that lands it inside ε and not before.
|
||||
let mut counting = pending_at(0.5);
|
||||
counting.step(0.125); // exact in binary — the step's value is pinned, not approximated
|
||||
assert_eq!(counting.countdown, 0.375);
|
||||
assert!(!counting.has_new_mail());
|
||||
assert!(!counting.take_notify());
|
||||
counting.step(10.0);
|
||||
assert_eq!(counting.countdown, 0.0);
|
||||
assert!(counting.has_new_mail());
|
||||
assert!(
|
||||
counting.take_notify(),
|
||||
"the crossing fires UPDATE_PENDING_MAIL"
|
||||
);
|
||||
}
|
||||
|
||||
/// The query reply stores verbatim and signals **unconditionally** — even when `HasNewMail()`
|
||||
/// did not change (wow-re `0x4ad605`). A transition-only fire would be a silent divergence.
|
||||
#[test]
|
||||
fn query_reply_stores_and_always_signals() {
|
||||
let mut p = MailPending::default();
|
||||
p.apply_query_reply(-86400.0);
|
||||
assert_eq!(p.countdown, -86400.0);
|
||||
assert!(!p.has_new_mail());
|
||||
assert!(p.take_notify(), "false -> false still signals");
|
||||
|
||||
p.apply_query_reply(0.0);
|
||||
assert!(p.has_new_mail());
|
||||
assert!(p.take_notify());
|
||||
}
|
||||
|
||||
/// Sending the query stamps "no mail" but does **not** signal — so the icon keeps its face for
|
||||
/// the round trip and moves when the reply lands (wow-re: `0x4ade25` stamps, `0x4ad605` fires).
|
||||
#[test]
|
||||
fn sending_the_query_stamps_without_signalling() {
|
||||
let mut p = pending_at(0.0);
|
||||
assert!(p.has_new_mail());
|
||||
p.on_query_sent();
|
||||
assert_eq!(p.countdown, MAIL_TIME_NO_MAIL);
|
||||
assert!(!p.has_new_mail());
|
||||
assert!(!p.take_notify());
|
||||
}
|
||||
|
||||
/// `SMSG_RECEIVED_MAIL`'s set-value ladder (`0x4ad620`), all four branches.
|
||||
#[test]
|
||||
fn received_mail_ladder() {
|
||||
// Busy (a mailbox window is open): arm the deferred refresh, leave the countdown alone.
|
||||
let mut busy = pending_at(-86400.0);
|
||||
busy.apply_received_mail(0.0, true);
|
||||
assert_eq!(
|
||||
busy.countdown, -86400.0,
|
||||
"the icon does not move under the player"
|
||||
);
|
||||
assert!(!busy.take_notify());
|
||||
assert!(busy.take_refresh(), "the close will re-ask the server");
|
||||
assert!(
|
||||
!busy.take_refresh(),
|
||||
"and the flag is consumed by that one read"
|
||||
);
|
||||
|
||||
// Not busy, |delay| < eps: store and signal — vmangos's only case.
|
||||
let mut now = pending_at(-86400.0);
|
||||
now.apply_received_mail(0.0, false);
|
||||
assert_eq!(now.countdown, 0.0);
|
||||
assert!(now.has_new_mail());
|
||||
assert!(now.take_notify());
|
||||
|
||||
// Not busy, a real delay, current countdown negative: store it, no signal.
|
||||
let mut seeded = pending_at(-1.0);
|
||||
seeded.apply_received_mail(120.0, false);
|
||||
assert_eq!(seeded.countdown, 120.0);
|
||||
assert!(!seeded.take_notify());
|
||||
|
||||
// Not busy, a real delay, current countdown positive: keep the *smaller* (tighten), silent.
|
||||
let mut tighten = pending_at(120.0);
|
||||
tighten.apply_received_mail(60.0, false);
|
||||
assert_eq!(tighten.countdown, 60.0);
|
||||
tighten.apply_received_mail(300.0, false);
|
||||
assert_eq!(tighten.countdown, 60.0, "a later estimate never loosens it");
|
||||
assert!(!tighten.take_notify());
|
||||
}
|
||||
|
||||
/// The mechanism that actually clears the icon (decision 0913): opening a letter arms the
|
||||
/// deferred refresh, and the close consumes it into a re-query whose stamp darkens the icon.
|
||||
#[test]
|
||||
fn reading_mail_arms_the_refresh_that_the_close_consumes() {
|
||||
let mut p = pending_at(0.0);
|
||||
assert!(p.has_new_mail());
|
||||
assert!(!p.take_refresh(), "nothing armed before a letter is opened");
|
||||
|
||||
p.arm_refresh(); // the mark-as-read sender, on every letter open
|
||||
assert!(p.has_new_mail(), "still lit while the window is open");
|
||||
|
||||
// The close core: flag set -> re-ask, and the sender stamps "no mail".
|
||||
assert!(p.take_refresh());
|
||||
p.on_query_sent();
|
||||
assert!(!p.has_new_mail());
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
|
||||
@@ -329,6 +329,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -407,6 +408,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -513,6 +515,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
slots.insert(
|
||||
@@ -531,6 +534,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -723,6 +727,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -809,6 +814,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -937,6 +943,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -1223,6 +1230,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
|
||||
locked: false,
|
||||
equip_slots: vec![20],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
s.set_inventory_slots(inv);
|
||||
s.run("BenillaBagBarSlot_OnEnter(BenillaBagBarSlot1)")
|
||||
|
||||
@@ -94,6 +94,7 @@ fn inventory_with_head_item() -> InventorySlots {
|
||||
locked: false,
|
||||
equip_slots: vec![1],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots
|
||||
}
|
||||
@@ -118,6 +119,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
benilla_ui::script::ContainerState {
|
||||
|
||||
@@ -326,6 +326,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
|
||||
@@ -45,6 +45,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
ContainerState {
|
||||
|
||||
@@ -38,6 +38,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
ContainerState {
|
||||
@@ -192,6 +193,7 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
|
||||
@@ -679,6 +679,7 @@ fn backpack() -> ContainerState {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
ContainerState {
|
||||
|
||||
@@ -66,6 +66,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
locked: false,
|
||||
equip_slots: vec![1],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
s.set_inventory_slots(inv);
|
||||
s.set_item_template(
|
||||
@@ -110,6 +111,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
readable: false,
|
||||
creator: None,
|
||||
flags: 0,
|
||||
enchants: Vec::new(),
|
||||
},
|
||||
);
|
||||
s.set_container(
|
||||
@@ -266,6 +268,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
|
||||
locked: false,
|
||||
equip_slots: vec![1],
|
||||
creator: None,
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
s.set_inventory_slots(inv);
|
||||
s.set_item_template(
|
||||
|
||||
@@ -43,7 +43,7 @@ use crate::cooldowns::Cooldowns;
|
||||
use crate::items::Items;
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::Selection;
|
||||
use crate::ui_action::{cast_target, send_spell_cast, usable, PlayerActions, Spells};
|
||||
use crate::ui_action::{cast_target, usable, CastCommit, CastLadder, PlayerActions, Spells};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
|
||||
@@ -182,24 +182,11 @@ fn feed_shapeshift_bar(
|
||||
|
||||
/// Drain `CastShapeshiftForm`'s queued form spells: cancel the active form (unless its
|
||||
/// `SpellShapeshiftForm.dbc` flags block it — the silent no-op), cast any other.
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set (send_spell_cast's own)
|
||||
fn drain_shapeshift_casts(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
commands: Res<NetCommands>,
|
||||
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
cast_items: Res<Items>,
|
||||
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
mut pending: ResMut<crate::ui_cast::PendingCast>,
|
||||
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
|
||||
mut cooldowns: ResMut<Cooldowns>,
|
||||
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
|
||||
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
|
||||
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
|
||||
mut ground: ResMut<crate::ui_action::SpellTargeting>,
|
||||
mut ecs: Commands,
|
||||
mut ladder: CastLadder,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -210,39 +197,27 @@ fn drain_shapeshift_casts(
|
||||
.next()
|
||||
.map(|s| s.0.unit_shapeshift_form())
|
||||
.unwrap_or(0);
|
||||
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
||||
let d = ladder.spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
||||
// 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
|
||||
let row = ladder
|
||||
.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 });
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(ClientCommand::CancelAura { spell_id });
|
||||
continue;
|
||||
}
|
||||
Some(false) => continue,
|
||||
None => {}
|
||||
}
|
||||
debug!("ui_shapeshift: cast form {spell_id}");
|
||||
send_spell_cast(
|
||||
spell_id,
|
||||
&targeting.context(),
|
||||
&commands,
|
||||
&self_player,
|
||||
spells.as_deref(),
|
||||
&cast_items,
|
||||
&mut sheath,
|
||||
&mut ecs,
|
||||
&mut pending,
|
||||
&mut queued_melee,
|
||||
&mut cooldowns,
|
||||
&mut cast_errors,
|
||||
&mut auto_repeat,
|
||||
&mut trade_skill_opens,
|
||||
&mut ground,
|
||||
);
|
||||
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{
|
||||
cast_target, melee_auto_attack_icon, ranged_weapon_icon, send_spell_cast, PlayerActions, Spells,
|
||||
cast_target, melee_auto_attack_icon, ranged_weapon_icon, CastCommit, CastLadder, PlayerActions,
|
||||
Spells,
|
||||
};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
@@ -349,23 +350,10 @@ fn leading_number(s: &str) -> u32 {
|
||||
|
||||
/// Drain `take_spell_casts` through the SAME cast tail `drain_action_uses` uses for a SPELL-kind
|
||||
/// action (decision 0216 §8: "root-cause rule: one cast-send path") — `ui_action::send_spell_cast`.
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
|
||||
fn drain_spell_casts(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
commands: Res<NetCommands>,
|
||||
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
cast_items: Res<Items>,
|
||||
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
mut pending: ResMut<crate::ui_cast::PendingCast>,
|
||||
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
|
||||
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
|
||||
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
|
||||
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
|
||||
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
|
||||
mut ground: ResMut<crate::ui_action::SpellTargeting>,
|
||||
mut ecs: Commands,
|
||||
mut ladder: CastLadder,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -377,11 +365,14 @@ fn drain_spell_casts(
|
||||
// (`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(sp), Some(store)) =
|
||||
(ladder.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
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(crate::net::ClientCommand::CancelAura { spell_id });
|
||||
continue;
|
||||
@@ -391,7 +382,8 @@ fn drain_spell_casts(
|
||||
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
|
||||
let _ = ladder
|
||||
.commands
|
||||
.0
|
||||
.send(crate::net::ClientCommand::CancelAura { spell_id });
|
||||
continue;
|
||||
@@ -408,23 +400,7 @@ fn drain_spell_casts(
|
||||
"ui_spellbook: cast {spell_id} (target {:?})",
|
||||
targeting.selection.guid
|
||||
);
|
||||
send_spell_cast(
|
||||
spell_id,
|
||||
&targeting.context(),
|
||||
&commands,
|
||||
&self_player,
|
||||
spells.as_deref(),
|
||||
&cast_items,
|
||||
&mut sheath,
|
||||
&mut ecs,
|
||||
&mut pending,
|
||||
&mut queued_melee,
|
||||
&mut cooldowns,
|
||||
&mut cast_errors,
|
||||
&mut auto_repeat,
|
||||
&mut trade_skill_opens,
|
||||
&mut ground,
|
||||
);
|
||||
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ use crate::creature_anim::{CastEvent, CastEventKind};
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{cast_target, send_spell_cast, PlayerActions, Spells};
|
||||
use crate::ui_action::{cast_target, CastCommit, CastLadder, PlayerActions, Spells};
|
||||
use crate::ui_items::count_of;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_spellbook::SkillLines;
|
||||
@@ -429,63 +429,17 @@ const ATTR_IS_TRADESKILL: u32 = 0x20;
|
||||
/// Drain the Lua intents and run the repeat machine: `DoTradeSkill` latches the count and casts
|
||||
/// through the ONE cast-send path (0216 §8); our own GO for the latched spell re-casts until dry;
|
||||
/// a fail or `CloseTradeSkill` stops everything.
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set (send_spell_cast's own)
|
||||
fn drain_trade_skill(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<TradeSkillOpen>,
|
||||
mut repeat: ResMut<TradeSkillRepeat>,
|
||||
mut cast_events: MessageReader<CastEvent>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
commands: Res<NetCommands>,
|
||||
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
|
||||
// The cast catalog + the item cache the pre-send totem/reagent check reads (decision
|
||||
// 0552) + the targeting mode the one cast path threads (0792), one tuple param (Bevy's
|
||||
// 16-param ceiling).
|
||||
spell_inputs: (
|
||||
Option<Res<Spells>>,
|
||||
Res<Items>,
|
||||
ResMut<crate::ui_action::SpellTargeting>,
|
||||
),
|
||||
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
mut pending: ResMut<crate::ui_cast::PendingCast>,
|
||||
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
|
||||
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
|
||||
mut cast_errors: ResMut<crate::ui_action::CastErrors>,
|
||||
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
|
||||
mut opens: ResMut<TradeSkillOpens>,
|
||||
mut ecs: Commands,
|
||||
mut ladder: CastLadder,
|
||||
) {
|
||||
let (spells, cast_items, mut ground) = spell_inputs;
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let mut cast =
|
||||
|spell_id: u32,
|
||||
ecs: &mut Commands,
|
||||
pending: &mut crate::ui_cast::PendingCast,
|
||||
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
|
||||
cooldowns: &mut crate::cooldowns::Cooldowns,
|
||||
cast_errors: &mut crate::ui_action::CastErrors,
|
||||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||||
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>| {
|
||||
send_spell_cast(
|
||||
spell_id,
|
||||
&targeting.context(),
|
||||
&commands,
|
||||
&self_player,
|
||||
spells.as_deref(),
|
||||
&cast_items,
|
||||
sheath,
|
||||
ecs,
|
||||
pending,
|
||||
queued_melee,
|
||||
cooldowns,
|
||||
cast_errors,
|
||||
auto_repeat,
|
||||
&mut opens,
|
||||
&mut ground,
|
||||
);
|
||||
};
|
||||
|
||||
for (spell_id, count) in script.take_trade_skill_dos() {
|
||||
if open.line.is_none() {
|
||||
@@ -495,20 +449,11 @@ fn drain_trade_skill(
|
||||
debug!("ui_tradeskill: DoTradeSkill({spell_id}) ×{count}");
|
||||
repeat.spell_id = spell_id;
|
||||
repeat.remaining = count.max(1);
|
||||
cast(
|
||||
spell_id,
|
||||
&mut ecs,
|
||||
&mut pending,
|
||||
&mut queued_melee,
|
||||
&mut cooldowns,
|
||||
&mut cast_errors,
|
||||
&mut auto_repeat,
|
||||
&mut sheath,
|
||||
);
|
||||
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
|
||||
}
|
||||
|
||||
// The repeat machine's continuation: our own cast edges for the latched spell.
|
||||
let self_entity = self_player.single().ok().map(|(e, _)| e);
|
||||
let self_entity = ladder.self_player.single().ok().map(|(e, _)| e);
|
||||
for ev in cast_events.read() {
|
||||
if repeat.remaining == 0 || Some(ev.entity) != self_entity || ev.spell_id != repeat.spell_id
|
||||
{
|
||||
@@ -522,16 +467,7 @@ fn drain_trade_skill(
|
||||
"ui_tradeskill: recast {} ({} left)",
|
||||
repeat.spell_id, repeat.remaining
|
||||
);
|
||||
cast(
|
||||
repeat.spell_id,
|
||||
&mut ecs,
|
||||
&mut pending,
|
||||
&mut queued_melee,
|
||||
&mut cooldowns,
|
||||
&mut cast_errors,
|
||||
&mut auto_repeat,
|
||||
&mut sheath,
|
||||
);
|
||||
ladder.send(repeat.spell_id, &targeting.context(), CastCommit::Spell);
|
||||
}
|
||||
}
|
||||
CastEventKind::Fail => {
|
||||
|
||||
Reference in New Issue
Block a user