From 2d83c8392c56b38afd5f42886f7b8cf61d2d4a64 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 3 Aug 2026 17:53:22 +0700 Subject: [PATCH] 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 --- crates/benilla-assets/src/m2.rs | 25 ++ crates/benilla-formats/src/item_visuals.rs | 95 ++++- crates/benilla-formats/src/lib.rs | 2 +- crates/benilla-protocol/src/events.rs | 14 +- crates/benilla-protocol/src/events/decode.rs | 11 +- crates/benilla-protocol/src/messages/items.rs | 111 ++++- crates/benilla-protocol/src/messages/mail.rs | 17 +- crates/benilla-protocol/src/messages/mod.rs | 2 +- .../benilla-protocol/src/messages/opcode.rs | 8 + .../benilla-protocol/src/messages/packet.rs | 19 +- crates/benilla-protocol/src/messages/parse.rs | 15 +- .../src/messages/update_object/fields/mod.rs | 1 + .../messages/update_object/fields/player.rs | 33 ++ crates/benilla-protocol/src/world/session.rs | 7 +- .../src/world/writer/items.rs | 4 +- crates/benilla-protocol/tests/items.rs | 31 +- crates/benilla-protocol/tests/mail.rs | 22 +- crates/benilla-ui/src/script/char_stats.rs | 6 + crates/benilla-ui/src/script/container.rs | 28 ++ crates/benilla-ui/src/script/cursor.rs | 1 + crates/benilla-ui/src/script/cursor/doll.rs | 6 + crates/benilla-ui/src/script/item_stats.rs | 5 + crates/benilla-ui/src/script/mod.rs | 2 +- .../src/script/tests/tooltip_item/bindings.rs | 1 + .../script/tests/tooltip_item/mechanics.rs | 314 ++++++++++++++ .../benilla-ui/src/script/tooltip_item/mod.rs | 2 + .../src/script/tooltip_item/names.rs | 4 + .../src/script/tooltip_item/render.rs | 141 ++++++- crates/benilla/assets/ui/MinimapCluster.xml | 9 +- crates/benilla/src/aura_visual.rs | 28 +- crates/benilla/src/aura_visual/tests.rs | 4 + crates/benilla/src/capture/fixtures.rs | 2 + crates/benilla/src/clouds/mod.rs | 23 +- crates/benilla/src/debug_panel/visibility.rs | 21 +- crates/benilla/src/entities.rs | 67 ++- crates/benilla/src/entities/attach/mod.rs | 128 ++---- .../benilla/src/entities/equipment/resolve.rs | 8 +- .../benilla/src/entities/equipment/spawn.rs | 2 + crates/benilla/src/entities/item_glow.rs | 55 ++- crates/benilla/src/entities/live_display.rs | 29 +- crates/benilla/src/entities/mount.rs | 384 ++++++++++++++--- crates/benilla/src/entities/mount/tests.rs | 318 ++++++++++++++ crates/benilla/src/entities/spell_fx/mod.rs | 114 ++++- crates/benilla/src/items.rs | 136 ++++++ crates/benilla/src/model_fade.rs | 13 +- crates/benilla/src/model_render.rs | 269 ++++++++++++ crates/benilla/src/net.rs | 10 +- crates/benilla/src/net/apply.rs | 13 +- crates/benilla/src/net/apply/mail.rs | 59 +-- crates/benilla/src/net/apply/session.rs | 2 +- crates/benilla/src/net/io.rs | 4 +- crates/benilla/src/particles.rs | 9 +- crates/benilla/src/particles/sim.rs | 164 +++++--- crates/benilla/src/player/camera.rs | 38 +- crates/benilla/src/portrait/mod.rs | 1 + crates/benilla/src/ribbons.rs | 24 +- crates/benilla/src/sky_order.rs | 31 +- crates/benilla/src/target/click.rs | 9 +- crates/benilla/src/terrain.rs | 19 + crates/benilla/src/ui_action/cast_send.rs | 394 ++++++++++++++++-- crates/benilla/src/ui_action/drain.rs | 117 ++---- crates/benilla/src/ui_action/mod.rs | 5 +- crates/benilla/src/ui_action/targeting.rs | 41 +- crates/benilla/src/ui_char.rs | 51 ++- crates/benilla/src/ui_craft.rs | 69 +-- crates/benilla/src/ui_inspect.rs | 30 +- crates/benilla/src/ui_items/drain.rs | 65 ++- crates/benilla/src/ui_items/equip_error.rs | 153 +++++-- crates/benilla/src/ui_items/feed.rs | 123 ++++-- crates/benilla/src/ui_items/mod.rs | 362 ++++++---------- .../src/{ui_mail.rs => ui_mail/mod.rs} | 187 +++------ crates/benilla/src/ui_mail/pending.rs | 309 ++++++++++++++ .../benilla/src/ui_script/action_bar_tests.rs | 1 + crates/benilla/src/ui_script/bag_tests.rs | 8 + .../benilla/src/ui_script/character_tests.rs | 2 + crates/benilla/src/ui_script/chat_tests.rs | 1 + .../src/ui_script/delete_item_tests.rs | 1 + crates/benilla/src/ui_script/escape_tests.rs | 2 + .../benilla/src/ui_script/game_menu_tests.rs | 1 + .../src/ui_script/tooltip_compare_tests.rs | 3 + crates/benilla/src/ui_shapeshift.rs | 45 +- crates/benilla/src/ui_spellbook.rs | 46 +- crates/benilla/src/ui_tradeskill.rs | 74 +--- 83 files changed, 3831 insertions(+), 1179 deletions(-) create mode 100644 crates/benilla/src/entities/mount/tests.rs rename crates/benilla/src/{ui_mail.rs => ui_mail/mod.rs} (81%) create mode 100644 crates/benilla/src/ui_mail/pending.rs diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 753753b6..498c2091 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -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(); diff --git a/crates/benilla-formats/src/item_visuals.rs b/crates/benilla-formats/src/item_visuals.rs index d1b58e9b..0f9cc48a 100644 --- a/crates/benilla-formats/src/item_visuals.rs +++ b/crates/benilla-formats/src/item_visuals.rs @@ -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, + names: HashMap, } -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) -> 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, names: HashMap) -> 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 + '_ { + pub fn iter_visuals(&self) -> impl Iterator + '_ { 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 Ok(ItemVisualCatalog { visuals }) } -/// Load `SpellItemEnchantment.dbc`'s visual column off the patch chain. -pub fn load_enchant_visual_catalog(chain: &mut Chain) -> Result { +/// 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 { 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 enchants.visual_count(), + "far more enchants print a name than carry a glow" + ); + } } diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f1d2c820..420d2899 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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; diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 8fbb9e5e..fc3da36b 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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). diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index c21b991e..e0435430 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -173,6 +173,15 @@ pub fn decode(packet: ServerPacket) -> Vec { 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 { 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 }] } diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 89775267..3da8d954 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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) -> Vec { +/// 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 { 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. diff --git a/crates/benilla-protocol/src/messages/mail.rs b/crates/benilla-protocol/src/messages/mail.rs index 4529d4f6..1ecbb337 100644 --- a/crates/benilla-protocol/src/messages/mail.rs +++ b/crates/benilla-protocol/src/messages/mail.rs @@ -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 { - 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 { + read_f32_le(r) } /// Read `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode both directions — our request is an EMPTY diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 0e13e4c2..2cfffb01 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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::{ diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 86a15c9a..b8e77699 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index f532c753..b6e513d5 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index d224e915..9d98412f 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -428,6 +428,14 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { 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 { 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)?, }, diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index a775b6b9..5251ca89 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 69c631ed..5be92b93 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -44,6 +44,39 @@ impl ObjectFields { pub fn item_text_id(&self) -> Option { 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 { + (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 { diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index ab96412c..158f6cef 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -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(), + ), ) } diff --git a/crates/benilla-protocol/src/world/writer/items.rs b/crates/benilla-protocol/src/world/writer/items.rs index dc6a6df7..aee04136 100644 --- a/crates/benilla-protocol/src/world/writer/items.rs +++ b/crates/benilla-protocol/src/world/writer/items.rs @@ -35,11 +35,11 @@ impl WorldWriter { bag_index: u8, slot: u8, spell_slot: u8, - go_target: Option, + 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), ) } diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index ba771cee..89591349 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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")); diff --git a/crates/benilla-protocol/tests/mail.rs b/crates/benilla-protocol/tests/mail.rs index 2a7a874d..244772a2 100644 --- a/crates/benilla-protocol/tests/mail.rs +++ b/crates/benilla-protocol/tests/mail.rs @@ -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:?}"), + } } } diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index 766de85a..d73f104c 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -196,6 +196,12 @@ pub struct InvSlotView { /// The RESOLVED `ITEM_FIELD_CREATOR` name — the equipped-item tooltip's green /// "" line (see [`super::container::ContainerSlot::creator`], the bag twin). pub creator: Option, + /// 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, } /// The inventory-slot snapshot: index 0 = ammo, 1..=19 the equipment slots, 20..=23 the four diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 5d7ddf85..3c6c27ce 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -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, +} + +/// 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, } /// 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)); diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index c1b60b31..c6f509ef 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -460,6 +460,7 @@ mod tests { readable: false, creator: None, flags: 0, + enchants: Vec::new(), }, ); crate::script::container::ContainerState { diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index f6505b39..a35112a0 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -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( diff --git a/crates/benilla-ui/src/script/item_stats.rs b/crates/benilla-ui/src/script/item_stats.rs index 16dfa853..0d6d7ba4 100644 --- a/crates/benilla-ui/src/script/item_stats.rs +++ b/crates/benilla-ui/src/script/item_stats.rs @@ -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 `` 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 diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index aa876144..2fd336e4 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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}; diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 4c77fb63..7a063069 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -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( diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs index 65c5a521..5a458b1e 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs @@ -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:?}" + ); +} + +/// `` (§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 == "") + .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(&"") && texts.contains(&"Stamina +7"), + "an instance's known roll replaces the placeholder — got {texts:?}" + ); +} diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index d981656f..190700dd 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -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). diff --git a/crates/benilla-ui/src/script/tooltip_item/names.rs b/crates/benilla-ui/src/script/tooltip_item/names.rs index 7ed2c48f..032c780c 100644 --- a/crates/benilla-ui/src/script/tooltip_item/names.rs +++ b/crates/benilla-ui/src/script/tooltip_item/names.rs @@ -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`). diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index 62aa06e3..0a782dea 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -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, /// 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(("".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_` 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"); + } +} diff --git a/crates/benilla/assets/ui/MinimapCluster.xml b/crates/benilla/assets/ui/MinimapCluster.xml index 3bafc78b..58248a65 100644 --- a/crates/benilla/assets/ui/MinimapCluster.xml +++ b/crates/benilla/assets/ui/MinimapCluster.xml @@ -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 diff --git a/crates/benilla/src/aura_visual.rs b/crates/benilla/src/aura_visual.rs index 07a18636..f224f23f 100644 --- a/crates/benilla/src/aura_visual.rs +++ b/crates/benilla/src/aura_visual.rs @@ -695,6 +695,7 @@ type AuraParts<'w, 's> = Query< Option<&'static InteriorLit>, Option<&'static RenderFade>, Has, + Has, ), // 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, 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, + ); } } } diff --git a/crates/benilla/src/aura_visual/tests.rs b/crates/benilla/src/aura_visual/tests.rs index 974594e8..be1b05e5 100644 --- a/crates/benilla/src/aura_visual/tests.rs +++ b/crates/benilla/src/aura_visual/tests.rs @@ -247,6 +247,8 @@ fn app_with_chains() -> App { app.add_message::(); app.add_message::(); app.add_message::(); + // The water-plane twin map the alpha author composes with (empty here — no water in a fixture). + app.init_resource::(); 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::(); + // The water-plane twin map the author composes with (empty — no water in a fixture). + app.init_resource::(); app.add_systems(Update, apply_aura_alpha); let fm = FadeMaterials { diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index 10ee5780..5f9a9bf6 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -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 diff --git a/crates/benilla/src/clouds/mod.rs b/crates/benilla/src/clouds/mod.rs index 0b6a4c22..c19b2158 100644 --- a/crates/benilla/src/clouds/mod.rs +++ b/crates/benilla/src/clouds/mod.rs @@ -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, light: Res, @@ -103,7 +107,19 @@ fn tick_clouds( clock: Option>, mut images: ResMut>, mut materials: ResMut>, + underwater: Res, + mut was_submerged: Local, ) { + // 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); diff --git a/crates/benilla/src/debug_panel/visibility.rs b/crates/benilla/src/debug_panel/visibility.rs index 92c5d39b..037ead10 100644 --- a/crates/benilla/src/debug_panel/visibility.rs +++ b/crates/benilla/src/debug_panel/visibility.rs @@ -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, + // 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, mut q: Query<( &ModelPart, &GlobalTransform, @@ -54,6 +59,7 @@ pub(super) fn apply_model_visibility( Option<&WmoGroupVis>, Option<&crate::doodad_anim::MatAnim>, Has, + Has, )>, // 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(); } diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index e5b8c92b..277b2e75 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -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::() + .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)), diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index 2d91d12b..b6a7ba08 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -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, Option<&super::BoneAttach>, &NetEntity), - With, - >, + 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>, @@ -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::(); - 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::(); } // Real model: submesh children inherit the entity's (pose-driven) transform; bake scale onto it. let kind = match net.kind { diff --git a/crates/benilla/src/entities/equipment/resolve.rs b/crates/benilla/src/entities/equipment/resolve.rs index cf2f4d3d..b7aef47c 100644 --- a/crates/benilla/src/entities/equipment/resolve.rs +++ b/crates/benilla/src/entities/equipment/resolve.rs @@ -84,6 +84,7 @@ fn ammo_attach(ranged_inv_type: Option, nock_latched: bool) -> Option /// 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, @@ -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>, // 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>, + enchants: Option>, ) { 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, [], diff --git a/crates/benilla/src/entities/equipment/spawn.rs b/crates/benilla/src/entities/equipment/spawn.rs index 6ed18dfe..337d645e 100644 --- a/crates/benilla/src/entities/equipment/spawn.rs +++ b/crates/benilla/src/entities/equipment/spawn.rs @@ -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); diff --git a/crates/benilla/src/entities/item_glow.rs b/crates/benilla/src/entities/item_glow.rs index deb0b805..0e4c9354 100644 --- a/crates/benilla/src/entities/item_glow.rs +++ b/crates/benilla/src/entities/item_glow.rs @@ -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, } 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, ) -> 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); } } diff --git a/crates/benilla/src/entities/live_display.rs b/crates/benilla/src/entities/live_display.rs index bb655fe3..2cd54295 100644 --- a/crates/benilla/src/entities/live_display.rs +++ b/crates/benilla/src/entities/live_display.rs @@ -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 { } /// 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). diff --git a/crates/benilla/src/entities/mount.rs b/crates/benilla/src/entities/mount.rs index c923513c..632ad9d1 100644 --- a/crates/benilla/src/entities/mount.rs +++ b/crates/benilla/src/entities/mount.rs @@ -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::()` + 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, + Option<&'static super::BoneAttach>, + &'static NetEntity, + ), + With, +>; + +/// 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, + 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::(); + 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 = 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, >, + children: MountChildren, + conforms: Query<(), With>, + creatures: Option>, ) { - 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::() - .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::(); + } + 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::() + .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; diff --git a/crates/benilla/src/entities/mount/tests.rs b/crates/benilla/src/entities/mount/tests.rs new file mode 100644 index 00000000..ca8f51e8 --- /dev/null +++ b/crates/benilla/src/entities/mount/tests.rs @@ -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::()`'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 { + self.app + .world() + .entity(self.rider) + .get::() + .map(|c| c.0) + } + + /// The rig's current model frame. + fn frame(&self) -> Entity { + self.app + .world() + .entity(self.rider) + .get::() + .unwrap() + .joints_root + } + + fn parent_of(&self, e: Entity) -> Option { + self.app.world().entity(e).get::().map(|c| c.0) + } + + fn applied(&self) -> u32 { + self.app + .world() + .entity(self.rider) + .get::() + .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::() + .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()); +} diff --git a/crates/benilla/src/entities/spell_fx/mod.rs b/crates/benilla/src/entities/spell_fx/mod.rs index 9279b3a9..bf2209e0 100644 --- a/crates/benilla/src/entities/spell_fx/mod.rs +++ b/crates/benilla/src/entities/spell_fx/mod.rs @@ -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) { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_asset::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, attach_spell_fx); + let roots: Vec = 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)> { + app.world() + .entity(unit) + .get::() + .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]))]); + } +} diff --git a/crates/benilla/src/items.rs b/crates/benilla/src/items.rs index 6ff17d4b..76bd52b9 100644 --- a/crates/benilla/src/items.rs +++ b/crates/benilla/src/items.rs @@ -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); + +/// 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, + enchants: Option<&Enchants>, +) -> Vec { + let Some(enchants) = enchants else { + return Vec::new(); + }; + let lines: Vec = 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 { + 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| -> Vec { + 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()); + } } diff --git a/crates/benilla/src/model_fade.rs b/crates/benilla/src/model_fade.rs index 1b628474..5b390b53 100644 --- a/crates/benilla/src/model_fade.rs +++ b/crates/benilla/src/model_fade.rs @@ -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