diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 75fbad1f..0f6498f6 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -68,10 +68,16 @@ pub struct AnimClip { /// (wow-re `anim-id-resolution.md`, op4 `0x71248a..`) — [`ModelAnimations::pick_variation`]. pub frequency: u16, /// The sequence's `(minReplay, maxReplay)` range (`M2Sequence` +0x18/+0x1c): the client rolls a - /// play count `R` at every arm and multiplies it into the play window, so a clamp-flag one-shot - /// runs `R` times before freezing (wow-re `loop-replay-fidget.md`; a loop-flag sequence ignores - /// it). Benilla expresses the same window as a `RepeatAnimation::Count(R)` on the one-shot play. + /// play count `R = max(1, min + ((rand()·(max−min)) >> 15))` at every arm and multiplies it into + /// the play window (`windowHi = now + span·R`), so a clamp-flag one-shot runs `R` times before + /// freezing. Benilla expresses that as a `RepeatAnimation::Count(R)` on the one-shot play. /// `(0, 0)` — the overwhelming majority — rolls to `R = 1`. + /// + /// **A loop-flag sequence does NOT ignore it** (this doc said so, sourced from + /// `loop-replay-fidget.md`'s unit-lane reading, and decision 0768 corrected it): the window still + /// governs, and for a *placed doodad* its expiry is what fires the re-arm that rolls a fresh + /// variation — `(0, 0)` ⇒ `R = 1` ⇒ a re-roll every single loop. That is the whole mechanism + /// behind the Blasted Lands lightning wandering instead of strobing from one fixed spot. pub replay: (u32, u32), } diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 43c65559..5b94f41a 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -70,6 +70,12 @@ pub struct WmoModel { /// fog resolve (`crate::wmo_portal` in the app), per wow-re `rf-weather-emission-timeline` /// ROUND 5 (`0x69de20`). pub fogs: Vec, + /// The root's **MOSB** skybox model (`.m2`), drawn as the sky backdrop while the camera stands in + /// a group whose flags carry `0x40000` — see `crate::wmo_sky` in the app for the gate and + /// `benilla_formats::WmoGroupInfo::show_skybox` for how that bit was identified. `None` for all + /// but a handful of roots (`benilla-extract skyboxscan`); carrying it here costs one `Option` + /// per building and saves the sky lane a second root parse. + pub skybox: Option, /// Per-group **walking-collision** triangles (WoW model space, indexed by absolute group index) — /// the Leg-A face set for the current-group **down-ray** (`crate::wmo_portal`). The faithful "which /// room is the camera in" test casts a ray straight down from the eye against the same face set the @@ -666,6 +672,7 @@ impl AssetLoader for WmoModelLoader { portal_refs: portals.refs.clone(), group_nav, fogs: root.fogs().to_vec(), + skybox: root.skybox().map(str::to_owned), group_collision_tris, group_camera_only_tris, group_collision_bounds, @@ -700,6 +707,7 @@ mod doodad_base_tests { fn grp(interior: bool) -> WmoGroupInfo { WmoGroupInfo { interior, + show_skybox: false, bbox_min: [-1.0; 3], bbox_max: [1.0; 3], } diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 3726d357..3d2b0212 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -293,6 +293,13 @@ enum Command { /// models if omitted. prefix: Option, }, + /// Sweep every WMO ROOT and cross-tab the two halves of the skybox mechanism: the root's + /// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so + /// this is what *identifies* it — the bit turning out to be set on a group iff its root names + /// a skybox is the whole argument that it means "draw this root's skybox here". Also the + /// population instrument: which 1.12 buildings replace the `Light.dbc` gradient dome with an + /// authored sky (Stratholme's burning city is the headline), and how much of each does it + Skyboxscan, /// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a world position at a /// time of day — the band-semantics instrument (which row holds which colour at which hour; /// the celestial-diffuse band question, decision 0485). ⚠ This is ONE params record RAW — @@ -556,6 +563,7 @@ fn main() -> Result<()> { scan::partscan(&mut chain, mask, prefix.as_deref())?; } Command::M2lightscan { prefix } => scan::m2lightscan(&mut chain, prefix.as_deref())?, + Command::Skyboxscan => scan::skyboxscan(&mut chain)?, Command::Lightbands { map, x, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index f27f9c80..3c52bd5e 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -2766,3 +2766,100 @@ pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); Ok(()) } + +/// Sweep every WMO **root** in the chain and report the two halves of the skybox mechanism: which +/// roots author a **MOSB** skybox model, and which carry groups flagged `0x40000` +/// ([`benilla_formats::WmoGroupInfo::show_skybox`]). +/// +/// This is the instrument that *identifies* the flag. `0x40000` is not documented anywhere we can +/// cite, so its meaning is established by correlation: if the bit is set on a group **iff** that +/// group's root names a skybox, the bit is the per-group "draw the root's skybox here" gate and +/// nothing else. The summary prints that cross-tab, so the claim is re-checkable in one command +/// against the shipped files rather than trusted from a decision record. +/// +/// It is also the population instrument for the mechanism: which buildings in 1.12 replace the +/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it (Stratholme's +/// city shell sets the bit on its open streets — which are authored as INTERIOR groups — but not on +/// its enclosed rooms). +pub fn skyboxscan(chain: &mut Chain) -> Result<()> { + // Roots only: a group file is `_NNN.wmo`, and only the root carries MOSB/MOGI. + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".wmo") + && !l + .strip_suffix(".wmo") + .and_then(|s| s.rsplit('_').next()) + .is_some_and(|tail| tail.len() == 3 && tail.bytes().all(|b| b.is_ascii_digit())) + }) + .collect(); + + // The cross-tab that identifies the flag: roots with/without a MOSB × groups with/without 0x40000. + let (mut both, mut mosb_only, mut flag_only, mut neither) = (0u32, 0u32, 0u32, 0u32); + let mut scanned = 0u32; + let mut hits: Vec<(String, String, usize, usize)> = Vec::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else { + continue; // a group file that slipped the name filter, or a truncated root + }; + scanned += 1; + let groups = root.group_infos(); + let flagged = groups.iter().filter(|g| g.show_skybox).count(); + match (root.skybox(), flagged > 0) { + (Some(sky), true) => { + both += 1; + hits.push((name, sky.to_string(), flagged, groups.len())); + } + (Some(sky), false) => { + mosb_only += 1; + hits.push(( + name, + format!("{sky} (NO group sets 0x40000)"), + 0, + groups.len(), + )); + } + (None, true) => { + flag_only += 1; + hits.push((name, "(no MOSB)".into(), flagged, groups.len())); + } + (None, false) => neither += 1, + } + } + + hits.sort(); + println!("{:<62} {:>7} skybox model", "WMO root", "groups"); + for (name, sky, flagged, total) in &hits { + println!("{name:<62} {flagged:>3}/{total:<3} {sky}"); + } + println!(); + println!("{scanned} WMO root(s) scanned"); + println!(" MOSB skybox AND >=1 group with 0x40000 : {both}"); + println!(" MOSB skybox but NO group with 0x40000 : {mosb_only}"); + println!(" group(s) with 0x40000 but NO MOSB : {flag_only}"); + println!(" neither : {neither}"); + if flag_only == 0 { + println!( + "\n=> 0x40000 NEVER appears without a MOSB ({flag_only} counter-examples in {scanned} \ + roots): the bit is the per-group 'draw this root's MOSB skybox' gate." + ); + println!( + " The converse does NOT hold ({mosb_only} root(s) name a skybox no group asks for), \ + so the GATE IS THE FLAG, not the chunk — a renderer keyed off MOSB alone would show \ + skies the reference never shows." + ); + } else { + println!( + "\n=> {flag_only} group(s) set 0x40000 with no MOSB to draw — the flag is NOT the \ + skybox gate; re-derive it before building on it." + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/models/wmo/root.rs b/crates/benilla-formats/src/models/wmo/root.rs index 6f6bb419..db62f68e 100644 --- a/crates/benilla-formats/src/models/wmo/root.rs +++ b/crates/benilla-formats/src/models/wmo/root.rs @@ -53,6 +53,13 @@ pub struct WmoGroupInfo { /// `groupFlags & 0x48 == 0` — neither EXTERIOR (`0x8`) nor exterior-lit (`0x40`): a true interior /// group. The same test the client branches on at `0x6b3f90` and that [`crate::models::RenderSubmesh::interior`] uses. pub interior: bool, + /// `groupFlags & 0x40000` — **SHOW_SKYBOX**: this group draws the root's [`WmoRoot::skybox`] model + /// in place of the `Light.dbc` gradient dome. The bit's identity is a corpus correlation + /// (`benilla-extract skyboxscan`): across the 5875 chain it appears on a group **iff** its root + /// carries a non-empty MOSB — Stratholme's city shell sets it on 61 of 83 groups (its open streets, + /// which are authored as INTERIOR groups), while the sibling dungeon `Stratholme.wmo` (MOSB empty) + /// sets it on none of 92. + pub show_skybox: bool, /// Group bounding-box min, WMO model space (WoW axes). pub bbox_min: [f32; 3], /// Group bounding-box max, WMO model space (WoW axes). @@ -71,6 +78,7 @@ pub struct WmoRoot { group_infos: Vec, portals: WmoPortals, fogs: Vec, + skybox: Option, } impl WmoRoot { @@ -109,6 +117,13 @@ impl WmoRoot { pub fn fogs(&self) -> &[WmoFog] { &self.fogs } + + /// The root's **MOSB** skybox model — the M2 a [`WmoGroupInfo::show_skybox`] group draws instead of + /// the `Light.dbc` gradient dome. `None` when the chunk is absent or holds only the empty string + /// (the overwhelming majority: five roots in the whole 5875 chain author one). + pub fn skybox(&self) -> Option<&str> { + self.skybox.as_deref() + } } /// Parse a WMO **root** file from bytes. `Err` if the bytes aren't a WMO root. @@ -122,6 +137,7 @@ pub fn parse_wmo_root(bytes: &[u8]) -> Result { let group_infos = parse_wmo_group_infos(bytes); let portals = parse_wmo_portals(bytes); let fogs = parse_wmo_fogs(bytes); + let skybox = parse_wmo_skybox(bytes); Ok(WmoRoot { parsed, doodads, @@ -129,9 +145,23 @@ pub fn parse_wmo_root(bytes: &[u8]) -> Result { group_infos, portals, fogs, + skybox, }) } +/// Parse the root's **MOSB** chunk — a single null-terminated model path, or the 4 zero bytes that +/// stand for "no skybox" (which is what almost every root carries). Returned normalized to `.m2`, the +/// extension the loaders take; the chunk itself names the authoring-time `.mdx`. +fn parse_wmo_skybox(bytes: &[u8]) -> Option { + let mosb = find_wmo_chunk(bytes, b"BSOM")?; + let end = mosb.iter().position(|&b| b == 0).unwrap_or(mosb.len()); + let raw = std::str::from_utf8(&mosb[..end]).ok()?.trim(); + if raw.is_empty() { + return None; + } + Some(crate::models::model_path(raw)) +} + /// Parse the root's **MOGI** group table (`[flags:u32][bbox:6×f32][nameOff:i32]`, stride 32) into the /// per-group interior flag + bounding box used to classify placed doodads (see [`WmoGroupInfo`]). The /// flags here mirror the per-group MOGP header flags ([`crate::models::RenderSubmesh::interior`] reads the latter). @@ -145,6 +175,7 @@ fn parse_wmo_group_infos(bytes: &[u8]) -> Vec { let flags = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]); WmoGroupInfo { interior: (flags & 0x48) == 0, + show_skybox: (flags & 0x40000) != 0, bbox_min: [f(4), f(8), f(12)], bbox_max: [f(16), f(20), f(24)], } diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index 1c062394..ab8bdbd5 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -1202,3 +1202,58 @@ fn tombstoned_paths_never_fall_through_to_the_base_copy() { ); } } + +/// The WMO **skybox** pair, against the shipped Stratholme files (`crate::models::wmo`, +/// `benilla::wmo_sky`): a root's MOSB model and the per-group `0x40000` gate that asks for it. +/// +/// The sibling roots are the whole point of the fixture. `Stratholme_B` is the burning **city** — it +/// names `StratholmeSkybox.m2` *and* 61 of its 83 groups set the bit, which is why the reference's +/// sky in King's Square is a painted red sky and not map 329's khaki `Light.dbc` gradient. +/// `Stratholme.wmo` is the **dungeon** next door: no MOSB, and not one of its 92 groups sets the bit. +/// A regression that drops either half (the chunk parse, or the flag) shows up here as one of the two +/// halves going quiet, and the two together are what keeps the gate off the chunk alone — four 1.12 +/// roots name a skybox no group ever asks for (`benilla-extract skyboxscan`). +#[test] +fn reads_the_wmo_skybox_and_its_per_group_gate() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + let city = chain + .read_file("World\\wmo\\Dungeon\\LD_Stratholme\\Stratholme_B.wmo") + .expect("read the Stratholme city WMO root"); + let city = benilla_formats::parse_wmo_root(&city).expect("parse the city root"); + assert_eq!( + city.skybox(), + Some("environments\\stars\\stratholmeskybox.m2"), + "the city root's MOSB names the painted sky (normalized .mdx -> .m2)" + ); + let asking = city.group_infos().iter().filter(|g| g.show_skybox).count(); + assert_eq!( + (asking, city.group_infos().len()), + (61, 83), + "the city's open streets ask for the skybox; its enclosed rooms don't" + ); + + let dungeon = chain + .read_file("World\\wmo\\Dungeon\\LD_Stratholme\\Stratholme.wmo") + .expect("read the Stratholme dungeon WMO root"); + let dungeon = benilla_formats::parse_wmo_root(&dungeon).expect("parse the dungeon root"); + assert_eq!( + dungeon.skybox(), + None, + "the dungeon root's MOSB is the empty string — no skybox" + ); + assert_eq!( + dungeon + .group_infos() + .iter() + .filter(|g| g.show_skybox) + .count(), + 0, + "and not one of its groups asks for one" + ); +} diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index c9376265..a6ab3fd6 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -158,7 +158,12 @@ pub struct ItemInfo { pub area: u32, /// `Map` — id from `Map.dbc`; a map-bound item's required map (0 = anywhere). pub map: u32, - /// `BagFamily` — a bag's accepted-item-type bitmask (soul bag, herb bag, …; 0 = a normal bag). + /// `BagFamily` — which specialised container an item belongs in (quiver 1, ammo pouch 2, soul + /// bag 3, herb 6, enchanting 7, engineering 8, **keys 9**; 0 = an ordinary item / ordinary bag). + /// An **enum, not a bitmask**, on this wire: 1.12 tests it for equality (vmangos + /// `ItemPrototype.h`'s `enum BagFamily`, and the reference's own `HasKey` `0x48ae90` compares + /// `template+0x1d0 == 9`) — it only became a mask in 2.x. `9` is what routes an item into the + /// keyring, and what [`crate::ObjectFields::player_keyring_slot`]'s slots hold. pub bag_family: u32, } @@ -466,17 +471,40 @@ pub const SLOT_PACK_FIRST: u8 = 23; /// The first equipped-bag player-array slot (`INVENTORY_SLOT_BAG_START`; bags occupy 19–22). pub const SLOT_BAG_FIRST: u8 = 19; +/// `TARGET_FLAG_GAMEOBJECT` — the cast-target bit that carries a GO guid (vmangos +/// `SpellDefines.h`; its `SpellCastTargets::read` is the only bit here that consumes bytes). +const TARGET_FLAG_GAMEOBJECT: u16 = 0x0800; +/// `TARGET_FLAG_LOCKED` — "this cast is opening a lock". Consumes **no** bytes on the read side +/// (vmangos `SpellCastTargets::read` tests only `TARGET_FLAG_GAMEOBJECT` for the GO guid), but the +/// real client ORs it into every OPEN_LOCK cast's mask (`0x6e44d9`/`0x6e44e1`, wow-re +/// `cursor-system.md` §8.4), so the mask we write is the mask it writes. +const TARGET_FLAG_LOCKED: u16 = 0x4000; + /// 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 self-shaped cast-target block -/// (mask 0 — consumables resolve their own implicit target server-side). -pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8) -> Vec { +/// 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 { let mut body = Vec::with_capacity(5); body.push(bag_index); body.push(slot); body.push(spell_slot); - body.extend_from_slice(&0u16.to_le_bytes()); + 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"); + } + } body } 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 1a010862..7a3d2050 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -92,10 +92,15 @@ impl ObjectFields { pub fn player_bank_bag_slot(&self, i: u8) -> Option { (i < 6).then(|| self.get_guid(FIELD_PLAYER_BANK_BAG_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_KEYRING_SLOT_1 + 2i` — the keyring's 32 item guids (absolute inventory slots - /// 81–112). benilla ships no keyring *container* (see `ui_items`'s slot-order note), but the - /// guids stream like every other slot array and the GameObject lock chain needs them: dungeon - /// keys live here, and the reference's key-slot resolver scans the keyring alongside the bags + /// `PLAYER_FIELD_KEYRING_SLOT_1 + 2i` — the keyring's item guids. The **field array is 32 + /// guids** (`UpdateFields_1_12_1.h`: size 64 dwords; vmangos `MAX_KEYRING_SLOTS 32`) and the + /// client's inventory walker treats all 32 as its keyring band (absolute slots 81–112, mode + /// bit `0x40` — wow-re `action-item-slot.md` §8.2), but only the **first 16 are addressable + /// positions** on this wire (vmangos `KEYRING_SLOT_START 81`, `KEYRING_SLOT_END 97`, whose own + /// comment reads "32 slots (only 16 are visible/accessible in UI)"), and only as many of those + /// as the player's level unlocks are usable (4/8/12/16 — `ui_items::keyring_size`). Feeds the + /// keyring container (decision 0765) and the + /// GameObject lock chain's key-slot resolver, which scans the keyring alongside the bags /// (wow-re `cursor-system.md` §8.4; decision 0752). `Some(0)` = empty. pub fn player_keyring_slot(&self, i: u8) -> Option { (i < 32).then(|| self.get_guid(FIELD_PLAYER_KEYRING_SLOT_1 + 2 * u16::from(i)))? diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 8bc460af..d93e6584 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -346,7 +346,7 @@ 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), + &messages::use_item(bag_index, slot, spell_slot, None), ) } diff --git a/crates/benilla-protocol/src/world/writer/items.rs b/crates/benilla-protocol/src/world/writer/items.rs index 75b7cd5d..93a1d653 100644 --- a/crates/benilla-protocol/src/world/writer/items.rs +++ b/crates/benilla-protocol/src/world/writer/items.rs @@ -27,12 +27,19 @@ impl WorldWriter { } /// Use an item by bag position (`CMSG_USE_ITEM`, layout in [`messages::use_item`]) — eat the - /// food, drink the potion, hearthstone home. The server answers with the effect (values + /// food, drink the potion, hearthstone home. `go_target` aims the use at a GameObject, which is + /// how a KEY opens a locked door (decision 0769). The server answers with the effect (values /// deltas, a stack decrement/destroy) or `SMSG_CAST_RESULT` on refusal. - pub fn use_item(&mut self, bag_index: u8, slot: u8, spell_slot: u8) -> Result<()> { + pub fn use_item( + &mut self, + bag_index: u8, + slot: u8, + spell_slot: u8, + go_target: Option, + ) -> Result<()> { self.send( opcode::CMSG_USE_ITEM, - &messages::use_item(bag_index, slot, spell_slot), + &messages::use_item(bag_index, slot, spell_slot, go_target), ) } diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 94145eea..a45870f3 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -17,7 +17,27 @@ 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), hx("ff17000000")); + assert_eq!(messages::use_item(255, 23, 0, None), 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 + // PACKED guid. This is the packet the real client sends for a key (wow-re cursor-system.md + // §8.4: sender 0x6e54f0's item arm -> 0x6e57d8 push 0xab) and the only one vmangos will open a + // KEY-slot lock for (Spell::CanOpenLock requires m_CastItem, Spell.cpp:7892). + // + // 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)), + 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)), + hx("ff510200480101") + ); // CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot. assert_eq!(messages::auto_equip_item(255, 25), hx("ff19")); diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 93e9b35d..5d7ddf85 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -120,6 +120,12 @@ impl super::UiScript { } } + /// Push the app's answer to `HasKey()` — whether the player owns any `BagFamily` KEYS item + /// anywhere the reference's own search reaches. The keyring UI's one gate (decision 0765). + pub fn set_has_key(&mut self, has_key: bool) { + self.model_mut().has_key = has_key; + } + /// Drain the `(bag, slot)` pairs queued by `UseContainerItem` since the last call. pub fn take_container_uses(&mut self) -> Vec<(i64, u32)> { std::mem::take(&mut self.model_mut().container_uses) @@ -498,6 +504,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; + // HasKey() — "does this player own a key at all?" (5875 `0x48ae90`), the gate the main bar + // reads to decide whether the keyring exists in the UI. The reference pushes the NUMBER 1 on a + // hit and nil otherwise, not a boolean, and FrameXML only ever tests it for truth — so the + // shape is reproduced exactly rather than normalized to a bool. The search itself (BagFamily + // == 9 across equipment/bags/backpack/bank/keyring) is the app's, like every other item fact. + lua.globals().set( + "HasKey", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(if model.has_key { + Value::Integer(1) + } else { + Value::Nil + }) + })?, + )?; // ResetCursor — displayed mode back to the base (the world classifier's) mode (5875 // `0x48ac70` → `0x523d30`): clear the whole override, whichever `Show*Cursor` armed it. lua.globals().set( diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 07ccbd62..c3b4b79c 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -38,9 +38,12 @@ pub(crate) struct Model { /// The layout mutation epoch — tier 1 of the resolve change gate (the fingerprint is tier 2). /// Bumped by [`Model::touch_layout`] at every write path that can move the anchor solve's /// read set; `resolve` returns immediately while it still equals [`Self::layout_epoch_resolved`], - /// paying a `u64` compare instead of fingerprinting ~2k inputs. An idempotent-write burst (the - /// bag-hover re-enter loop) bumps this every frame and falls through to the fingerprint, which - /// absorbs it exactly as before — the tiers are complementary, not redundant. + /// paying a `u64` compare instead of fingerprinting ~2k inputs. Tier 2 remains the correctness + /// backstop for a write that bumps this without moving anything; it is not a licence to bump + /// per frame, because *reaching* it costs the whole-UI hash. The bag-hover re-enter loop used + /// to do exactly that (~0.65 ms/frame at solves=0) until its wrap-pin round trip was made + /// change-gated — `tooltip::append_line`, and the gate + /// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`. pub(crate) layout_epoch: u64, /// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose /// fingerprint matched, proving the input set (seeds included) has stopped moving. `None` @@ -252,6 +255,11 @@ pub(crate) struct Model { /// at `set_container` push time (the action-state pattern), read by /// `GetContainerItemCooldown`. pub(crate) container_cooldowns: HashMap<(i64, u32), (f64, f64, bool)>, + /// `HasKey()` — whether the player owns any item of `BagFamily` KEYS, anywhere the reference's + /// own search reaches (equipment, bags, backpack, **bank**, keyring). App-resolved like every + /// other item fact; the engine holds no item knowledge of its own. Gates the whole keyring UI + /// (decision 0765). + pub(crate) has_key: bool, /// What the cursor carries (`PickupContainerItem`/`SplitContainerItem`/… set it; `None` = /// empty cursor) — the real client's transient drag state, typed by payload arm /// ([`cursor::CursorPayload`], decision 0216). Purely visual + intent-routing: no item moves @@ -692,6 +700,7 @@ impl Model { containers: HashMap::new(), container_uses: Vec::new(), container_cooldowns: HashMap::new(), + has_key: false, cursor: None, cursor_grid_shown: false, world_pick: cursor::WorldPick::default(), diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index 83fc7565..02b35794 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -8,6 +8,14 @@ use super::common::script; use crate::script::{Model, UiScript}; +/// The mutation epoch (tier 1's input): bumped by every write into the layout read set. +fn epoch(s: &UiScript) -> u64 { + s.lua() + .app_data_ref::() + .expect("model app_data") + .layout_epoch +} + /// How many times the fixpoint has run. fn solves(s: &UiScript) -> u64 { s.lua() @@ -256,6 +264,22 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() { solves_before, "a re-enter with identical content must not reopen the layout gate" ); + // …and it must not even reach tier 2. Tier 1 (the mutation epoch) is what makes a quiet frame + // FREE; tier 2 still hashes the whole UI's read set to decide "quiet" (~0.65 ms/frame measured + // with `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at solves=0). The re-enter loop used to + // pay that on every single hover frame, because `clear_content` wiped each line's wrap pin and + // `append_line` re-pinned the same width — a round trip inside one frame, two epoch bumps. + // Idempotent content must be epoch-silent, not merely fingerprint-absorbed. + let epoch_before = epoch(&s); + for _ in 0..10 { + frame(&mut s); + } + assert_eq!( + epoch(&s), + epoch_before, + "a re-enter with identical content must not touch the layout epoch at all — tier 1 has to \ + hold, or every hover frame pays the whole-UI fingerprint" + ); // The gate is shut because nothing MOVED — not because the tooltip went stale. Changed content // must still re-measure exactly its own line, and a measure that resizes the auto-sized plate diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 9429f3b2..966d24b4 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -274,10 +274,8 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { Some(KindState::Tooltip(t)) => (t.left_lines.clone(), t.right_lines.clone()), _ => return, }; - let mut any_size_dropped = false; for rh in lefts.into_iter().chain(rights) { if let Some(d) = model.region_data.get_mut(&rh) { - any_size_dropped |= d.size.is_some(); d.text = None; d.hidden = true; // `measured` is deliberately KEPT. It is a cache whose validity gate is its own @@ -294,18 +292,18 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { // `WOW_CAPTURE=ui-bag` vs `ui-tooltip`); the gate is // `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`. // - // A wrap-pinned width (the layout pass's wrap consumer) is per-content — the next - // content re-derives it. It feeds the measure key, so `append_line`'s wrap re-pin is - // what keeps a wrap-flagged line's key stable across the clear (gated by the same - // test, whose stack carries one). - d.size = None; + // The wrap pin (`size`) is likewise KEPT, and for the stronger reason: wiping it here + // was the last write that made the hover re-enter loop differ from itself. The wipe + // and `append_line`'s re-pin are a round trip inside ONE frame — same cell, same + // width, no observable state between them — but each half bumped the layout epoch, so + // tier 1 went dirty every hover frame and tier 2 hashed the whole UI to conclude + // nothing had moved (~0.65 ms/frame, `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at + // solves=0). The pin is now written from the line's own wrap flag at append time — + // set for a wrap line, cleared for a plain one — which covers the case this wipe + // existed for (a cell whose new content doesn't wrap) without a write per frame. + // A hidden, textless cell contributes nothing to layout in between either way. } } - if any_size_dropped { - // The size wipes above are gate-read-set writes; in the hover re-enter loop this fires - // every frame and tier 2 (the fingerprint) absorbs the burst — see the epoch's doc. - model.touch_layout(); - } if let Ok(t) = tip_mut(model, h) { t.num_lines = 0; t.min_width = 0.0; @@ -490,12 +488,16 @@ pub(super) fn append_line( // round-trip that the bag re-enter loop's per-frame content clear kept wiping out: wrapped // lines never converged live (the plate under-counted their rows forever — the bread/ // hearthstone spill). One step, no oscillation. - if wrap { - if let Some(d) = model.region_data.get_mut(&lh) { - d.size = Some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0)); - // A size write straight into the gate's read set. In the bag-hover re-enter loop - // this re-pins every frame after clear_content's wipe — tier 1 goes dirty and the - // FINGERPRINT (tier 2) absorbs the idempotent burst, exactly the pre-epoch behavior. + // The wrap pin is written from the LINE's own flag every append — set for a wrap line, + // cleared for a plain one — so a cell that changes role can never keep a stale pin, and + // `clear_content` needs no wipe of its own (which is what used to make this write differ + // every frame). The epoch bump is gated on a REAL change: in the hover re-enter loop the + // rebuilt line pins the same width it already carried, so tier 1 stays clean and the + // whole-UI fingerprint (tier 2) never runs — see `clear_content`'s note. + let pin = wrap.then_some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0)); + if let Some(d) = model.region_data.get_mut(&lh) { + if d.size != pin { + d.size = pin; model.touch_layout(); } } diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index e2c816df..3690dedf 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -39,6 +39,8 @@ use crate::widget::FrameHandle; const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0]; +/// `0xc0d420` = `0xff40c040` — the satisfiable-lock green (see [`TooltipTint::LockOpen`]). +const LOCK_OPEN: [f32; 4] = [64.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0, 1.0]; /// Unit names render gold (byte-verified `0xffffd200`); FrameXML recolors line 1 by reaction. const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0]; @@ -49,8 +51,16 @@ const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0]; pub enum TooltipTint { /// `0xc0cf60` — the requirement line's normal colour (the key-item "Requires %s"). White, - /// The "Locked" line's unmet colour, and the unknown-skill "Requires %s" (`0xffff0000`). + /// The "Locked" line's UNMET colour `0xc0d3a8` = `0xffff2020`, and the unknown-skill + /// "Requires %s" (whose own red is `0xffff0000` — a difference of 32/255 in G and B that no + /// eye resolves, so one slot serves both). Red, + /// The "Locked" line when the lock **is** satisfiable — `0xc0d420` = `0xff40c040` + /// (64,192,64). Deliberately NOT the tooltip's ordinary green `0xc0d3ac` = `0xff00ff00`: this + /// is the second rung of the skill-difficulty ramp `0x529fa0` (grey/green/yellow/orange/red, + /// static-init writers at `0x5290d0` &c.), which the lock line borrows wholesale, and it is a + /// visibly softer green than the one every other tooltip line uses. + LockOpen, } /// The rank word table — byte-verified `0x854158[]`: rare-elite prints ELITE, rare prints @@ -333,12 +343,18 @@ impl super::UiScript { /// (decisions 0276 / **0756**): the NAME (gold) followed by the lock lines the caller /// resolved, each with its own tint. /// - /// **Seated at the FrameXML default anchor — the bottom-right corner — exactly like the unit - /// flow.** This restores 0281's "units + GOs → corner" law and retires the 2026-07-13 interim - /// that seated the GO plate at the pointer: the director's 07-29 reference shot of the Searing - /// Gorge door shows the engine plate in the corner, not following the cursor, and their eye is - /// the ground truth the interim was resting on in the first place. The pointer-follow - /// ([`Self::world_tooltip_move`]) stays for the minimap blip, which really is cursor-seated. + /// **The anchor FORKS per object — it is not uniform** (decision 0766, correcting 0756). The + /// publisher's GameObject leg calls the picked object's `[obj->vtbl+0x5c]` and branches: + /// + /// - **true** (`0x492a01`) → `0x52ffe0(owner, 6, 0, 0)` — anchor-state **6**, the *cursor* + /// anchor. Corroborated by the loss path: `0x530ae0` hides an anchor-state-6 tooltip + /// **immediately** instead of arming the fade, which is how a pointer-following label behaves. + /// - **false** (`0x492a42`) → no `SetOwner` at all; the plate keeps its default corner seat. + /// + /// The **unit** leg (`0x492983`) never consults `+0x5c` — units are always corner-seated, which + /// is why the fork went unnoticed when 0756 made every GameObject corner-seated too. `cursor` + /// carries the caller's verdict: `Some(ui_xy)` = the cursor arm (and then + /// [`Self::world_tooltip_move`] follows the pointer), `None` = the corner. /// /// Same fade lifecycle as the unit flow (no `UPDATE_MOUSEOVER_UNIT` — that recolor is the /// unit line's). @@ -346,21 +362,44 @@ impl super::UiScript { &mut self, name: &str, lines: &[(String, TooltipTint)], + cursor: Option<(f32, f32)>, ) -> bool { - let (h, id) = { + let (h, id, root_id) = { let mut model = self.model_mut(); let Some(h) = model.arena.lookup("GameTooltip") else { return false; }; - let id = model.frame_id(h); - (h, id) + let Some(root) = model.arena.lookup("UIParent") else { + return false; + }; + let (id, root_id) = (model.frame_id(h), model.frame_id(root)); + (h, id, root_id) }; - // The same default-anchor handler the unit flow fires — FrameXML seats the plate at the - // bottom-right of UIParent. - if let Err(e) = - super::event::fire_widget_handler(&self.lua, id, "OnTooltipSetDefaultAnchor", vec![]) - { - self.push_error(e); + match cursor { + // The cursor arm: seated centred above the pointer, clamped by the tooltip frame's own + // flag. Compare-then-touch so a still pointer never re-layouts. + Some((ui_x, ui_y)) => { + let mut model = self.model_mut(); + let input = model.layout_inputs.entry(h).or_default(); + let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y); + let same = input.anchors.len() == 1 + && super::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + input.anchors = vec![new]; + model.touch_layout(); + } + } + // The corner arm: the same default-anchor handler the unit flow fires. + None => { + if let Err(e) = super::event::fire_widget_handler( + &self.lua, + id, + "OnTooltipSetDefaultAnchor", + vec![], + ) { + self.push_error(e); + } + } } let wrapper = match super::object::frame_wrapper(&self.lua, id) { Ok(w) => w, @@ -380,6 +419,7 @@ impl super::UiScript { let colour = match tint { TooltipTint::White => WHITE, TooltipTint::Red => RED, + TooltipTint::LockOpen => LOCK_OPEN, }; append_line(&self.lua, &wrapper, (text.clone(), colour), None, false)?; } diff --git a/crates/benilla/assets/shaders/wmo_skybox.wgsl b/crates/benilla/assets/shaders/wmo_skybox.wgsl new file mode 100644 index 00000000..5ecd7bdd --- /dev/null +++ b/crates/benilla/assets/shaders/wmo_skybox.wgsl @@ -0,0 +1,35 @@ +// The WMO **skybox** (`wmo_sky.rs`) — the authored sky a building swaps in for the Light.dbc +// gradient dome while the camera stands in one of its `0x40000` groups. Stratholme's burning city is +// the one place 1.12 content reaches this: `StratholmeSkybox.m2`, a static emissive cube whose three +// texture pairs are the painted red sky. +// +// GAMMA LANE (decision 0161): the model's batches are authored `emissive` + `two-sided` + opaque, so +// the faithful output is the texel's own gamma bytes, unlit and unmodulated — the same treatment the +// gradient dome's stops get. No fog: this IS the backdrop the world's fog converges toward, and the +// reference's sky pass runs before (and outside) the fog stage. +// +// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the box is drawn camera-anchored at model +// scale, so its geometry sits *inside* the world — the shell radius must not decide occlusion. +// Forcing the far depth makes it a true backdrop: it fills only the pixels no world geometry claimed. +#import bevy_pbr::{ + pbr_fragment::pbr_input_from_standard_material, + forward_io::VertexOutput, +} + +/// Reverse-Z "infinitely far" — the sky pass's forced depth (`sky_order.rs`). +const SKY_FAR_DEPTH: f32 = 0.0; + +struct WmoSkyboxOutput { + @location(0) color: vec4, + @builtin(frag_depth) depth: f32, +} + +@fragment +fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> WmoSkyboxOutput { + let pbr_input = pbr_input_from_standard_material(in, is_front); + var out: WmoSkyboxOutput; + // Opaque backdrop: the texel's raw gamma bytes, alpha forced to 1 (the sky art carries none). + out.color = vec4(pbr_input.material.base_color.rgb, 1.0); + out.depth = SKY_FAR_DEPTH; + return out; +} diff --git a/crates/benilla/assets/ui/ActionBar.xml b/crates/benilla/assets/ui/ActionBar.xml index 5d7ecf72..81a4fd7d 100644 --- a/crates/benilla/assets/ui/ActionBar.xml +++ b/crates/benilla/assets/ui/ActionBar.xml @@ -43,8 +43,12 @@ right-hand cluster is otherwise complete — the backpack toggle + four bag slots live in BagFrame.xml and the eight micro buttons in MicroMenu.xml (both anchored into BenillaActionBarArtFrame and seated over the art by BenillaActionBarArt_SeatAbove below), - and the performance meter is at the foot of this file. Only the KEYRING is still out, with - the keyring container itself (the ref hides that button by default anyway). + and the performance meter is at the foot of this file. The KEYRING landed with decision + 0765: its button and container live in BagFrame.xml (the ref's own split — + MainMenuBarBagButtons.xml / ContainerFrame.lua), and the bar's half of it — swapping the + two right-hand strips to the keyring art and sliding the performance meter over to make + room — is BenillaMainMenuBar_UpdateKeyRing below, exactly where the ref keeps it + (MainMenuBar.lua l.174-183). -->