the keyring lands, keys open doors, and a building can own the sky

- The keyring is built. Your first key makes the keyring button appear by
  the bags, opening the window the original keeps its dungeon keys in — the
  server had been sending the keys all along; nothing had ever shown them.
- Keys open their doors now. The client used to cast the open as a bare
  spell, which the server rightly refuses; it now sends the item-use the
  original sends, carrying which key from which slot. The tooltip plays
  along: "Locked" turns the soft green when something you carry opens it,
  and a key stops inventing a "Use: Opening" line it never had.
- A building can own the sky: a WMO can name its own painted skybox, and
  Stratholme's burning city now draws its near-black-to-red dome instead of
  the zone's khaki gradient. The exact rule for when it applies settles in
  the next snapshot.
- Signpost tooltips follow the cursor again — the tooltip anchor forks per
  object class, corner for doors, cursor for signs — and the Blasted Lands
  lightning stops striking the same spot for ever: a placed doodad re-rolls
  its animation variation every play-window, like the original.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-29 19:54:18 +07:00
co-authored by Claude Fable 5
parent 2536f04d56
commit 34fdbd1d16
50 changed files with 2985 additions and 252 deletions
+9 -3
View File
@@ -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),
}
+8
View File
@@ -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<WmoFog>,
/// 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<String>,
/// 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],
}
@@ -293,6 +293,13 @@ enum Command {
/// models if omitted.
prefix: Option<String>,
},
/// 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,
@@ -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 `<stem>_NNN.wmo`, and only the root carries MOSB/MOGI.
let names: Vec<String> = 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(())
}
@@ -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<WmoGroupInfo>,
portals: WmoPortals,
fogs: Vec<WmoFog>,
skybox: Option<String>,
}
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<WmoRoot> {
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<WmoRoot> {
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<String> {
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<WmoGroupInfo> {
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)],
}
+55
View File
@@ -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"
);
}
+33 -5
View File
@@ -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<u8> {
/// 5 spell effects — 0, the "use" effect, for a plain use), then a `SpellCastTargets` block.
///
/// `go_target` is the **key-in-a-lock** case (decision 0769): opening a locked door or chest with a
/// key is not a spell cast, it is *using the key at the object*, and the packet is this one with
/// `TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED` and the object's packed guid (wow-re
/// `cursor-system.md` §8.4, byte-read: the sender `0x6e54f0` discriminates on cast-item-vs-caster
/// and the item arm falls to `0x6e57d8 push 0xab`). It matters that this is USE_ITEM and not a bare
/// cast: `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot **only** when `m_CastItem` is set
/// (`Spell.cpp:7892`), which only this packet supplies. `None` writes mask 0 — the self-shaped
/// block a consumable sends, whose implicit target the server resolves itself.
pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, go_target: Option<u64>) -> Vec<u8> {
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
}
@@ -92,10 +92,15 @@ impl ObjectFields {
pub fn player_bank_bag_slot(&self, i: u8) -> Option<u64> {
(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<u64> {
(i < 32).then(|| self.get_guid(FIELD_PLAYER_KEYRING_SLOT_1 + 2 * u16::from(i)))?
+1 -1
View File
@@ -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),
)
}
@@ -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<u64>,
) -> 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),
)
}
+21 -1
View File
@@ -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"));
+22
View File
@@ -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::<Model>().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(
+12 -3
View File
@@ -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(),
@@ -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::<Model>()
.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
+20 -18
View File
@@ -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();
}
}
+56 -16
View File
@@ -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)?;
}
@@ -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<f32>,
@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<f32>(pbr_input.material.base_color.rgb, 1.0);
out.depth = SKY_FAR_DEPTH;
return out;
}
+72 -5
View File
@@ -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).
-->
<Script><![CDATA[
BENILLA_FALLBACK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
@@ -88,6 +92,69 @@
if art then frame:SetFrameLevel(art:GetFrameLevel() + 1) end
end
-- ── The keyring's half of the bar (ref MainMenuBar_UpdateKeyRing, MainMenuBar.lua
-- l.174-183; decision 0765) ──────────────────────────────────────────────────────────────
-- The keyring is not just a button bolted onto the bar: the bar's two RIGHT-hand strips are
-- re-textured to a variant plate that has a socket for it, and the performance meter slides
-- 8px further left out of the way. Both atlases are 256-wide 43px strips at the same on-
-- screen size and anchors, so only the file + TexCoords change (UI-MainMenuBar-KeyRing is
-- 256x128 and holds exactly the two bands the ref names: 0.3359375 * 128 = 43px each).
--
-- Called by BenillaKeyRingButton's own OnEvent (BagFrame.xml). Everything is nil-guarded:
-- this file loads without BagFrame.xml in several harnesses, and BagFrame.xml loads without
-- this one in ~25 bag/bank/escape/merchant tests — either way the missing half no-ops.
--
-- DIVERGENCE (decision 0765): the ref latches this ON forever the first time HasKey() is
-- true, via the saved variable SHOW_KEYRING (and pulses the button for 60s to announce it).
-- This engine has no saved variables, and a session-scoped latch would leak across a
-- character switch — showing a keyring on a character with no key — so the state is derived
-- from HasKey() on every BAG_UPDATE instead, in BOTH directions. Observably identical
-- unless you destroy your last key, which then un-shows the keyring rather than leaving an
-- empty one behind. The 60s pulse is out, consistently with decision 0304's "no button
-- pulse" for the micro buttons.
function BenillaMainMenuBar_UpdateKeyRing()
local show = HasKey and HasKey()
-- The two right-hand strips (BenillaActionBarTexture2 at x=+128, Texture3 at x=+384),
-- keyring plate or the ordinary dwarf plate. The dwarf coords are this file's own
-- authored ones (see the strips below) — restated here because this is the only code
-- that ever changes them back.
local strips = {
{ name = "BenillaActionBarTexture2",
keyring = 0.6640625, keyringBottom = 1.0,
dwarf = 0.33203125, dwarfBottom = 0.5 },
{ name = "BenillaActionBarTexture3",
keyring = 0.1640625, keyringBottom = 0.5,
dwarf = 0.08203125, dwarfBottom = 0.25 },
}
for _, strip in ipairs(strips) do
local tex = getglobal(strip.name)
if tex then
if show then
tex:SetTexture("Interface\\MainMenuBar\\UI-MainMenuBar-KeyRing")
tex:SetTexCoord(0, 1, strip.keyring, strip.keyringBottom)
else
tex:SetTexture("Interface\\MainMenuBar\\UI-MainMenuBar-Dwarf")
tex:SetTexCoord(0, 1, strip.dwarf, strip.dwarfBottom)
end
end
end
-- The performance meter's recess moves with the plate: -227 without the keyring,
-- -235 with it (ref l.180). Its own comment at the foot of this file has the geometry.
local perf = getglobal("BenillaPerformanceBarFrame")
local bar = getglobal("BenillaActionBar")
if perf and bar then
perf:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", (show and -235) or -227, -10)
end
local button = getglobal("BenillaKeyRingButton")
if button then
if show then button:Show() else button:Hide() end
end
-- Losing the last key with the keyring open would strand an orphan window; the ref
-- can't reach this state (its latch is one-way), so this is ours to decide.
local frame = getglobal("BenillaKeyRingFrame")
if frame and not show and frame:IsShown() then frame:Hide() end
end
-- The wire/API action id the MAIN bar's button i displays (the vanilla page formula).
-- INTERNAL to BenillaActionButton_Action below — the ONLY caller. A button path that
-- reaches past it computes a MAIN-bar id for a multibar button (base ignored), which
@@ -861,9 +928,9 @@
tree, and its explicit strata survives the parent's LOW subtree-force (attributes
apply before nested <Frames> are built).
SCOPE: the ref's MainMenuBar_UpdateKeyRing shifts this frame to -235 when the keyring
is shown. No keyring here (see the header's OUT list), so the un-shifted -227 is the
only position. -->
The -227 authored here is the NO-keyring position; BenillaMainMenuBar_UpdateKeyRing
(top of this file) re-points it to -235 while the keyring is shown, the ref's own
shift (MainMenuBar.lua l.180). Both offsets live in that one function. -->
<Frame name="BenillaPerformanceBarFrame" frameStrata="LOW">
<Size><AbsDimension x="16" y="64"/></Size>
<Anchors>
+228 -11
View File
@@ -53,6 +53,23 @@
-- unused tail via BenillaBagSlot_Update's physIndex-past-size branch, same as the backpack.
BENILLA_BAG_WINDOW_MAX_SLOTS = 20
-- The keyring's container id (ref MainMenuBarBagButtons.lua:1) — decision 0765. It is an
-- ordinary container in every respect the seam cares about: the app feeds it like any other
-- bag (its slots are the player descriptor's own 81.., no container object), and every
-- C_Container verb, the cursor, the pending-op lock and the wire drains take it unchanged.
-- What makes it special is only what the reference makes special: the window's art, its
-- open/close sound, and the fact that its capacity is not a wire field.
KEYRING_CONTAINER = -2
-- The keyring's usable slot count (ref GetKeyRingSize, ContainerFrame.lua:773). The
-- reference recomputes a level ladder in Lua (4 / 8 at 40 / 12 at 50 / 16 above 60) that the
-- server enforces identically (Player::GetMaxKeyringSize); benilla computes it once app-side
-- and feeds it as the container's own size, so this reads it back rather than keeping a
-- second copy of the ladder that could drift from the one the feed uses.
function GetKeyRingSize()
return C_Container.GetContainerNumSlots(KEYRING_CONTAINER)
end
-- BenillaBagSlot_OnLoad(button, physIndex): physIndex is the button's CHAIN position (1 =
-- bottom-right, growing up-and-left — the anchor order below), not the game slot. The ref's
-- own formula (index = size - j + 1, ContainerFrame.lua:426/446) maps chain position → game
@@ -251,9 +268,27 @@
if button then BenillaBagSlot_Update(button) end
end
local title = getglobal(frame:GetName() .. "Name")
if title then title:SetText(C_Container.GetBagName(bagId) or "") end
if title then
-- The keyring is titled from the localized KEYRING string, not GetBagName — it has
-- no bag ITEM to be named after (ref ContainerFrame.lua l.418).
if bagId == KEYRING_CONTAINER then
title:SetText(KEYRING)
else
title:SetText(C_Container.GetBagName(bagId) or "")
end
end
if bagId == 0 then
BenillaMoney_Set("BenillaBagMoney", GetMoney())
elseif bagId == KEYRING_CONTAINER then
-- Fixed art, again because there is no bag item: the keyring's own portrait
-- (ref ContainerFrame.lua l.419) and its own background variant, snug-fitted to the
-- level-gated slot count exactly like an equipped bag.
local portrait = getglobal(frame:GetName() .. "Portrait")
if portrait then
portrait:SetPortraitToTexture("Interface\\ContainerFrame\\KeyRing-Bag-Icon")
end
local size = C_Container.GetContainerNumSlots(bagId)
if size > 0 then BenillaBagWindow_FitBackground(frame, size) end
else
-- The window's portrait is the bag's OWN item icon (the real client's
-- C-side SetBagPortaitTexture), not the default backpack art. An equipped bag
@@ -293,12 +328,35 @@
-- well row; the MIDDLE tiles the remaining rows; the BOTTOM is the frame's bottom rim. The
-- slot buttons anchor UP from the frame's BOTTOMRIGHT (first at -12,9) and hide past `size`
-- (BenillaBagSlot_Update), so nothing here touches them — only the art + the frame height.
-- Which UI-Bag-Components variant a window is stitched from — the ref's own `bagTextureSuffix`
-- (ContainerFrame_GenerateFrame, ContainerFrame.lua l.326-334): the bank bags get the
-- tinted "-Bank" plate, the keyring the "-Keyring" one, an equipped bag the plain sheet.
-- All three atlases are 256x512 with the same band layout, so FitBackground's tex-coords
-- are shared verbatim and only the file differs. (Ours ALSO applies the "-Bank" arm, which
-- benilla had been quietly skipping — the bank popouts have been drawing ordinary bag art
-- since decision 0604.)
function BenillaBagWindow_BgTexture(bagId)
local suffix = ""
if bagId >= 5 then
suffix = "-Bank"
elseif bagId == KEYRING_CONTAINER then
suffix = "-Keyring"
end
return "Interface\\ContainerFrame\\UI-Bag-Components" .. suffix
end
function BenillaBagWindow_FitBackground(frame, size)
local name = frame:GetName()
local top = getglobal(name .. "BgTop")
local middle = getglobal(name .. "BgMiddle")
local bottom = getglobal(name .. "BgBottom")
if not (top and middle and bottom) then return end
-- Re-set every fit, as the ref does at every generate: the template authors the plain
-- sheet, and a window's kind is only known from its id.
local bg = BenillaBagWindow_BgTexture(frame:GetID())
top:SetTexture(bg)
middle:SetTexture(bg)
bottom:SetTexture(bg)
local rowH = 41 -- rowHeight
local rows = math.ceil(size / 4)
if rows < 1 then rows = 1 end
@@ -324,9 +382,9 @@
local middleH = 0
bottom:ClearAllPoints()
if rows > 1 then
-- A single middle piece spans at most ROWS_IN_BG_TEXTURE (6) rows; the reference cycles
-- up to MAX_BG_TEXTURES for bank bags / the keyring, which we don't render. Our largest
-- window is a 20-slot bag (4 middle rows), so one piece always suffices.
-- A single middle piece spans at most ROWS_IN_BG_TEXTURE (6) rows; the reference
-- cycles up to MAX_BG_TEXTURES for windows taller than that. Our largest window is a
-- 20-slot bag (4 middle rows) and the keyring caps at 16 (3), so one always suffices.
middleH = (rows - 1) * rowH - 9
middle:SetHeight(middleH)
middle:SetTexCoord(0, 1, 0.353515625, middleH / 512 + 0.353515625)
@@ -466,6 +524,7 @@
-- MainMenuBarBackpackButton, bags 1..4 → CharacterBag(N-1)Slot).
function BenillaBagWindow_BarButton(frame)
local name = frame:GetName()
if name == "BenillaKeyRingFrame" then return getglobal("BenillaKeyRingButton") end
for bagId = 0, 4 do
if name == BENILLA_BAG_WINDOWS[bagId + 1] then
if bagId == 0 then return getglobal("BenillaBagToggle") end
@@ -478,18 +537,22 @@
-- Benilla<Window>Frame_OnShow/_OnHide fn (used when the handler is more than the bare
-- PlaySound, or — as here — to carry the cite). Kits quoted from the real ContainerFrame.lua
-- ContainerFrame_OnShow/OnHide (l.124-142 / l.100-122), reused VERBATIM by every window
-- (backpack included) — the real fn's keyring fork (KeyRingOpen/Close) never applies, since
-- benilla ships no keyring window; every one of our five windows plays the plain
-- igBackPackOpen/igBackPackClose pair. Fired on every visibility transition (a Show/Hide from
-- a toggle button, the 'B' binding, a close button, or ESC), never at load (every window is
-- authored hidden="true", so none ever transitions on startup).
-- (backpack included), INCLUDING the real fn's keyring fork: the keyring has its own
-- jingling KeyRingOpen/KeyRingClose pair (both real SoundEntries kits), every other window
-- the plain igBackPackOpen/igBackPackClose. Fired on every visibility transition (a Show/Hide
-- from a toggle button, the 'B' binding, a close button, or ESC), never at load (every window
-- is authored hidden="true", so none ever transitions on startup).
--
-- The `this` legacy global (RF-0025, script/event.rs) names the window that just
-- shown/hidden — used here for the open-window STACK the layout law below reads, rather
-- than a per-window OnShow/OnHide body (which would defeat the whole point of sharing one
-- handler across five windows).
function BenillaBagFrame_OnShow()
PlaySound("igBackPackOpen")
if this:GetID() == KEYRING_CONTAINER then
PlaySound("KeyRingOpen")
else
PlaySound("igBackPackOpen")
end
-- The bar lights the open bag's button (ref ContainerFrame_OnShow l.124-131,
-- SetChecked(1)) — the window side is the source of truth, so a bag opened by any
-- path (the B key, a bar click, open-all at a vendor) lights its button.
@@ -499,7 +562,11 @@
end
function BenillaBagFrame_OnHide()
PlaySound("igBackPackClose")
if this:GetID() == KEYRING_CONTAINER then
PlaySound("KeyRingClose")
else
PlaySound("igBackPackClose")
end
-- ...and clears it on any close (ref ContainerFrame_OnHide l.84-95, SetChecked(0)).
local barButton = BenillaBagWindow_BarButton(this)
if barButton then barButton:SetChecked(0) end
@@ -642,6 +709,10 @@
-- fill in for the harnesses that load no patch chain.
BACKPACK_TOOLTIP = BACKPACK_TOOLTIP or "Backpack"
EQUIP_CONTAINER = EQUIP_CONTAINER or "Equip Container"
-- ref GlobalStrings.lua l.2472 / l.2810 — the keyring's window title + button tooltip, and
-- the full-keyring refusal.
KEYRING = KEYRING or "Keyring"
NO_EMPTY_KEYRING_SLOTS_ERROR = NO_EMPTY_KEYRING_SLOTS_ERROR or "Your keyring is full."
-- Both are PLAIN tooltips — the reference calls GameTooltip:SetText here, not
-- GameTooltip_AddNewbieTip, so unlike the micro buttons next door (0661) there is no second
@@ -679,6 +750,86 @@
if GameTooltip then GameTooltip:Hide() end
end
-- ── The keyring (decision 0765) ──────────────────────────────────────────────────────────
-- ref ToggleKeyRing (ContainerFrame.lua:757-771). The reference generates the window on
-- demand from a pool of generic ContainerFrames sized to GetKeyRingSize(); ours is a fixed
-- window like the other six, so the toggle is the bag bar's own show/hide shape and the
-- size arrives through the feed. The ref's IsOptionFrameOpen() guard has no analogue here
-- (our bag-bar buttons carry none either — the game menu greys the row instead, 0674), and
-- its SetButtonPulse stop is moot with no pulse to stop (0304).
function ToggleKeyRing()
local frame = getglobal("BenillaKeyRingFrame")
if not frame then return end
if frame:IsShown() then
frame:Hide()
else
BenillaBagWindow_Update(frame)
frame:Show()
end
end
-- ref PutKeyInKeyRing (ContainerFrame.lua:740-755) — dropping a held key onto the keyring
-- BUTTON files it in the first free slot. "Pick up" from an empty slot IS the place gesture
-- (PickupContainerItem is the one entry point: empty cursor picks up, a held cursor places),
-- which is why the reference's own line reads as a pickup.
--
-- One deliberate un-quirking: the ref passes `NO_EMPTY_KEYRING_SLOTS`, but GlobalStrings
-- only ever defined `NO_EMPTY_KEYRING_SLOTS_ERROR` — so the real client silently prints
-- nothing when your keyring is full. We print the string that exists.
function PutKeyInKeyRing()
for i = 1, GetKeyRingSize() do
if not C_Container.GetContainerItemInfo(KEYRING_CONTAINER, i) then
C_Container.PickupContainerItem(KEYRING_CONTAINER, i)
return
end
end
if BenillaErrorsFrame_AddMessage then
BenillaErrorsFrame_AddMessage(NO_EMPTY_KEYRING_SLOTS_ERROR, 1.0, 0.1, 0.1)
end
end
-- ref KeyRingButton's OnLoad/OnClick/OnReceiveDrag (MainMenuBarBagButtons.xml l.142-199).
-- The button is authored HIDDEN and only ever revealed by BenillaMainMenuBar_UpdateKeyRing
-- (ActionBar.xml), which is where the whole show/hide law and its divergence note live.
function BenillaKeyRingButton_OnLoad(button)
button:SetID(KEYRING_CONTAINER)
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button:RegisterForDrag("LeftButton")
-- The ref watches PLAYER_ENTERING_WORLD / VARIABLES_LOADED / BAG_UPDATE on the bar's art
-- frame; with no saved variable there is nothing for VARIABLES_LOADED to restore, and
-- BAG_UPDATE is what actually carries "a key just arrived".
button:RegisterEvent("PLAYER_ENTERING_WORLD")
button:RegisterEvent("BAG_UPDATE")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
BenillaKeyRingButton_OnEvent()
end
function BenillaKeyRingButton_OnEvent()
if BenillaMainMenuBar_UpdateKeyRing then BenillaMainMenuBar_UpdateKeyRing() end
end
function BenillaKeyRingButton_OnClick(button)
-- Holding something ⇒ file it; empty-handed ⇒ open/close the keyring (ref l.166-173).
if CursorHasItem() then
PutKeyInKeyRing()
else
ToggleKeyRing()
end
-- Re-derive the lit ring from the window, the same provisional-toggle tail every other
-- bag-bar button uses.
local frame = getglobal("BenillaKeyRingFrame")
button:SetChecked(frame and frame:IsShown())
end
-- ref l.180-184: the plain KEYRING title plus the ref's own empty AddLine (which reserves
-- the second row the binding key would print into — we ship no TOGGLEKEYRING binding, the
-- same gap the bag slots' "(F1)".."(F4)" suffixes have).
function BenillaKeyRingButton_OnEnter(button)
if not GameTooltip then return end
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
GameTooltip:SetText(KEYRING, 1.0, 1.0, 1.0)
end
function BenillaBagBarSlot_Update(button)
local invSlot = BenillaBagBarSlot_InvSlot(button)
local texture = GetInventoryItemTexture("player", invSlot)
@@ -1230,6 +1381,28 @@
</Scripts>
</Frame>
<!--
The keyring window (decision 0765) — id = KEYRING_CONTAINER. Structurally just a seventh
container window on the SAME template as the four equipped bags and the six bank popouts;
everything that makes it a keyring (the "-Keyring" plate, the KeyRing-Bag-Icon portrait, the
KEYRING title, the jingling open/close pair) is the id-keyed fork inside the shared handlers,
exactly as ContainerFrame.lua does it. Its 20 template buttons cover the keyring's 16-slot
ceiling; the level-gated remainder hides through the ordinary physIndex-past-size branch.
It is deliberately NOT in BENILLA_BAG_WINDOWS: the keyring is not one of "your bags", so 'B'
/ the backpack toggle / a vendor's auto-open never touch it, which is the reference's own
arrangement (OpenAllBags counts it out, and CloseAllBags is id-driven over 1..4). ESC still
closes it — UiPanels.xml's CloseAllWindows hides every open container, the ref's own loop.
-->
<Frame name="BenillaKeyRingFrame" id="-2" inherits="BenillaBagWindowTemplate">
<Scripts>
<OnLoad>BenillaBagFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaBagFrame_OnEvent(self)</OnEvent>
<OnShow>BenillaBagFrame_OnShow()</OnShow>
<OnHide>BenillaBagFrame_OnHide()</OnHide>
</Scripts>
</Frame>
<!--
The backpack toggle, RELOCATED onto the main action bar's right side so the bar reads whole
(the real client's MainMenuBarBackpackButton, ref-MainMenuBarBagButtons.xml l.54-64): a 37x37
@@ -1430,4 +1603,48 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<!--
The keyring button (decision 0765) — ref KeyRingButton, MainMenuBarBagButtons.xml l.142-199.
Sits at the FAR left of the bag row, past BenillaBagBarSlot4 (the ref's CharacterBag3Slot),
and is authored HIDDEN: nothing reveals it but BenillaMainMenuBar_UpdateKeyRing, and only
while HasKey() — the point being that a character with no key has no keyring at all, and the
bar's plate has no socket for one.
18x39 and the TexCoords are the ref's exactly, and they are not arbitrary: the three state
atlases are 32x64 holding one 18x39 button, so 18/32 = 0.5625 and 39/64 = 0.609375 crop it
1:1. The chain gap is OUR row's -6 rather than the ref's -5, so the button seats evenly
against the 36px slots this bar scaled its own row to.
Unlike the five bag buttons it is NOT in BENILLA_BAG_BUTTONS: its art is a state texture, not
an $parentIcon layer, so Disable_BagButtons' SetDesaturation(b.icon) has nothing to grey —
and the ref leaves it out of its own Disable_BagButtons for the same reason.
-->
<CheckButton name="BenillaKeyRingButton" hidden="true">
<Size><AbsDimension x="18" y="39"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot4" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-Button-KeyRing">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Button-KeyRing-Down">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Button-KeyRing-Highlight" alphaMode="ADD">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</HighlightTexture>
<!-- The lit ring while the keyring window is open — the bag row's own convention
(the ref drives the same state through UpdateMicroButtons' SetButtonState). -->
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnLoad>BenillaKeyRingButton_OnLoad(self)</OnLoad>
<OnEvent>BenillaKeyRingButton_OnEvent()</OnEvent>
<OnClick>BenillaKeyRingButton_OnClick(self)</OnClick>
<OnEnter>BenillaKeyRingButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- A key dragged onto the button files itself, the ref's own OnReceiveDrag (l.174-178). -->
<OnReceiveDrag>PutKeyInKeyRing()</OnReceiveDrag>
</Scripts>
</CheckButton>
</Ui>
+8 -1
View File
@@ -391,6 +391,13 @@
-- (It was three identical inline copies until the game menu needed a fourth.)
local BAG_WINDOWS = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4" }
-- ESC closes the KEYRING too, but nothing else here does. The ref's split, exactly: its
-- CloseAllWindows loops every ContainerFrame and hides whatever is shown (the keyring
-- included), while CloseAllBags/OpenBackpack/CloseBackpack are id-driven over the real bags
-- and never see container -2 (decision 0765). So the keyring is appended for the ESC path
-- alone — a vendor opening your bags must not open your keyring with them.
local ESC_CLOSES = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4", "BenillaKeyRingFrame" }
function CloseAllWindows(ignoreCenter)
local bagsVisible = nil
@@ -398,7 +405,7 @@
-- BenillaBagToggle_OnClick), ESC must close all of them, not just the backpack. The bag
-- windows are NOT UIPanels (no slot registration) — they must be closed here explicitly,
-- exactly as the real client closes its ContainerFrames outside the slot system.
for _, name in ipairs(BAG_WINDOWS) do
for _, name in ipairs(ESC_CLOSES) do
local bag = getglobal(name)
if bag and bag:IsShown() then
bag:Hide()
+12
View File
@@ -37,6 +37,7 @@ pub(super) struct WorldReadout<'w, 's> {
area: Res<'w, crate::terrain_stream::CurrentArea>,
areas: Option<Res<'w, crate::area::AreaTableRes>>,
interior: Res<'w, crate::wmo_portal::CurrentAreaInterior>,
skybox: Res<'w, crate::wmo_sky::CameraWmoSkybox>,
streamer: Res<'w, crate::terrain_stream::TerrainStreamer>,
self_guid: Res<'w, crate::net::SelfGuid>,
names: ResMut<'w, crate::names::NameCache>,
@@ -92,6 +93,17 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
if world.interior.0.is_some() {
line.push_str(" · indoors");
}
// The WMO skybox engagement, right beside the interior claim it derives from: both come off
// the camera's down-ray seed, so when the backdrop flips between the building's painted sky
// and the Light.dbc gradient, this is the line that says which — and whether the claim moved
// under it. From the chair the two are only distinguishable by colour.
match world.skybox.0.as_deref() {
Some(path) => {
let leaf = path.rsplit('\\').next().unwrap_or(path);
line.push_str(&format!(" · skybox {leaf}"));
}
None => line.push_str(" · sky gradient"),
}
ui.label(line);
ui.label(egui::RichText::new(format!("leaf area {leaf}")).color(OVERLAY_TEXT_DIM));
}
+396 -47
View File
@@ -2,22 +2,28 @@
//! props) animate: flags wave, windmills turn, flame bones jiggle.
//!
//! Byte ground (wow-5875-re `system/animation/scratch/doodad-anim-host.md`, VERIFIED): a doodad's M2
//! instance is armed **once at load** — bone 0, the model's file-order-first sequence
//! (`animations[0].id`), `linkFlag=1` — and the kernel loops it forever via its own modulo-wrap;
//! **global sequences loop with zero arming**, clock-driven; and the kernel ticks as a **draw-time
//! side effect**, so a culled model is simply not evaluated. Because sampling is clock-indexed
//! (`cursor = clock − startOffset`), a re-appearing model shows the pose the shared clock dictates —
//! pausing costs nothing and drifts nothing.
//! instance is armed at load — bone 0, animation id 0, `linkFlag=1` — and then **re-arms itself every
//! play-window, for ever**, rolling a fresh frequency-weighted variation each time (§5, decision
//! 0768: the watchdog `0x719370` fires on `now ≥ windowHi`, enqueues the doodad-only completion
//! callback `0x6951b0` that `0x695100` installed at `[model+0x70]`, and that callback re-runs op4
//! with `variationIdx = -1`, which writes the next `windowHi` and clears the latch — self-sustaining).
//! **Global sequences loop with zero arming**, clock-driven. The cycle is gated on **linkage** — the
//! per-frame walk `0x7074b0` only advances models spliced into the scene list, which the doodad drain
//! `0x683f80` does per **in-range** doodad — not on the draw, so a doodad behind the camera keeps
//! cycling. Because sampling is clock-indexed (`cursor = clock − startOffset`), a re-appearing model
//! shows the pose the shared clock dictates — pausing costs nothing and drifts nothing.
//!
//! benilla's translation: the spawn site ([`crate::terrain_stream`]) classifies each placed model by
//! [`classify`] — the ~90% with no animated channel stay on today's static path (measured,
//! `benilla-extract doodadscan`) — and an animated one spawns the skinned twin + a joint hierarchy
//! under an anim-root entity carrying [`DoodadAnimHost`]: an `AnimationPlayer` looping the
//! first-sequence clip (the one-time arm), and/or a [`GlobalSeqDrive`] for the free-running channels.
//! [`gate_doodad_anim`] here is the draw-time tick made explicit: animation runs iff any of the
//! doodad's submeshes is actually drawn (the `Visibility` verdict the debug-panel authority composes
//! from far-clip + the size-bucketed distance fade + the WMO portal cull), and a resume seeks to
//! `(now − spawned_at) mod duration` — the ref's shared-clock phase, exactly.
//! under an anim-root entity carrying [`DoodadAnimHost`]: an `AnimationPlayer` on the armed clip,
//! re-rolled every window by [`reroll_doodad_variation`], and/or a [`GlobalSeqDrive`] for the
//! free-running channels. [`gate_doodad_anim`] here is the draw-time tick made explicit: animation
//! runs iff any of the doodad's submeshes is actually drawn (the `Visibility` verdict the debug-panel
//! authority composes from far-clip + the size-bucketed distance fade + the WMO portal cull), and a
//! resume seeks to `(now − armed_at) mod duration` — the ref's shared-clock phase, measured from the
//! current arm. Note the two gates are deliberately different: the *draw* gates the pose, the
//! *linkage* (here: residency) gates the variation cycle.
use bevy::animation::graph::AnimationNodeIndex;
use bevy::animation::AnimatedBy;
@@ -29,6 +35,44 @@ use benilla_assets::{bone_target_id, AnimClip, M2Model, ModelAnimations, ModelSk
use crate::creature_anim::GlobalSeqDrive;
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
/// (wow-re `doodad-anim-host.md` §5, decision 0768). Every doodad's variation roll draws from **one**
/// shared stream, which is what de-syncs a stand of identical props: not a per-placement seed, just
/// consecutive draws off one sequence.
///
/// This replaced a position-derived hash. The hash de-synced instances correctly but was *permanent* —
/// the same placement rolled the same variation on every re-stream and every run — which is exactly
/// how the Blasted Lands lightning ended up striking from one fixed spot for ever instead of wandering
/// the Tainted Scar (bug B63's residual). Captures are unaffected: [`spawn_anim_host`] returns `None`
/// under a capture scenario, so no doodad animates in a golden frame and determinism is untouched.
#[derive(Resource)]
pub(crate) struct AnimRng(u32);
impl Default for AnimRng {
fn default() -> Self {
Self(1) // the CRT's own initial seed
}
}
impl AnimRng {
/// One `rand()` draw: `seed = seed·214013 + 2531011`, result `(seed >> 16) & 0x7fff`.
fn draw(&mut self) -> u16 {
self.0 = self.0.wrapping_mul(214_013).wrapping_add(2_531_011);
((self.0 >> 16) & 0x7fff) as u16
}
/// The play-window's replay count `R = max(1, min + ((rand()·(max−min)) >> 15))` — the reference's
/// `windowHi = now + span·R` (wow-re §5). `replay = (0, 0)`, the overwhelming majority and the
/// lightning's own value, always yields `R = 1`: one loop per window, so the variation re-rolls
/// every single pass. The draw is taken unconditionally — it is one sub-expression of the
/// reference's formula, so the shared stream advances the same way whatever the span.
fn replay_count(&mut self, replay: (u32, u32)) -> u32 {
let (lo, hi) = replay;
let r = lo + ((u32::from(self.draw()) * hi.saturating_sub(lo)) >> 15);
r.max(1)
}
}
/// What a placed doodad model animates — decision 0130's content gate, decided per model at spawn.
pub(crate) enum DoodadAnimTier<'a> {
/// No animated bone channel: today's static path, untouched (no joints, no player — the ~90%
@@ -77,16 +121,20 @@ pub(crate) struct AnimHostSpawn {
pub slot: u16,
/// The looping first-sequence node + duration, `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
/// The **file sequence slot** this placement's arm actually rolled ([`AnimClip::seq_index`]) —
/// the axis every per-sequence bake is keyed on, and what the placement's particle emitters
/// must sample their rate/gate tracks against. `None` on the gseq-only tier (no arm).
/// The **file sequence slot** the load arm seeded ([`AnimClip::seq_index`]) — the axis every
/// per-sequence bake is keyed on. `None` on the gseq-only tier (no arm).
///
/// This exists because the variation roll below is a *per-placement* decision that more than
/// one consumer depends on. The emitters used to pin slot 0 regardless, which silently killed
/// every emitter whose rate is keyed only in a later variation — 947 of them across 184 models
/// (`benilla-extract partslotscan`), including the Blasted Lands lightning strike that keys
/// its whole burst in slot 1 and therefore never fired once (decision 0760, bug B63).
/// This is the *loader's* var-0 seed only. It is no longer what the placement ends up playing:
/// [`reroll_doodad_variation`] overrides it on the first frame with a real weighted roll and
/// keeps re-rolling every play-window (decision 0768), exactly as the reference's holder setup
/// `0x695100` lands its `variationIdx = -1` arm after the loader's var-0 one. Consumers that
/// must track the *current* slot therefore read the host's live player instead — the emitters
/// ride [`crate::particles::EmitClock::Host`].
pub seq: Option<usize>,
/// The animation id the arm rolls its variation over (id 0 — the loader-idle seed's id, resolved
/// through the model's own `playableAnimationLookup`). `None` on the gseq-only tier: nothing was
/// armed, so there is no play-window and nothing to re-roll.
pub anim_id: Option<u16>,
}
/// Spawn the animation host for one placed M2, if [`classify`] says it animates: an anim-root entity
@@ -139,35 +187,33 @@ pub(crate) fn spawn_anim_host(
}
let mut clip_info = None;
let mut armed_seq = None;
let mut arm_id = None;
if let DoodadAnimTier::FirstSeq(head) = tier {
// The effective load arm rolls `variationIdx = −1` — a frequency-weighted pick over the
// first sequence's variation chain (wow-re `doodad-anim-host.md` §4a: the holder setup
// `0x695100` re-arms over the loader's var-0 seed, byte-cited; §5 pair still to close).
// Seeded off the placement position, not a global RNG: deterministic per placement (a
// re-streamed tile re-picks the same variation — the reference re-rolls, invisible either
// way), and instances de-sync exactly like the reference (14 gryphon roosts stop fidgeting
// in lockstep). Single-variation chains (the overwhelming majority) pick the head — today's
// behavior, so the roll costs nothing there.
let t = transform.translation;
let roll = ((t.x.to_bits() ^ t.y.to_bits().rotate_left(11) ^ t.z.to_bits().rotate_left(22))
.wrapping_mul(0x9E37_79B9)
>> 17) as u16
& 0x7fff;
let clip = anims.pick_variation(head.anim_id, roll).unwrap_or(head);
// The **loader's** seed only — `0x70ebd0`'s var-0 arm on the head of the chain. The real
// pick is the holder setup's second op4 call (`0x695100`, `variationIdx = -1`), which lands
// *after* it and is the effective arm (wow-re §4a); here that second arm is
// [`reroll_doodad_variation`]'s first pass, which fires the same frame because the host is
// born with an already-expired window. Splitting it that way is not a convenience: it is
// the one code path the reference has, since every later re-arm is byte-identical to the
// holder's first one.
let mut player = AnimationPlayer::default();
player.play(clip.node).repeat();
commands
.entity(root)
.insert((player, AnimationGraphHandle(anims.graph.clone())));
player.play(head.node).repeat();
commands.entity(root).insert((
player,
AnimationGraphHandle(anims.graph.clone()),
// The re-roll needs the variation chain, and — now that a placed doodad's played
// sequence CHANGES — so does every per-sequence consumer that resolves off this host
// (`playing_seq`): the emitters' rate/enable tracks, the material-alpha sampler.
anims.clone(),
));
for (i, &j) in joints.iter().enumerate() {
commands
.entity(j)
.insert((bone_target_id(i as u16), AnimatedBy(root)));
}
clip_info = Some((clip.node, clip.duration));
// The slot the roll landed on — not necessarily 0, and every per-sequence bake the
// placement's other consumers read is keyed on it (see [`AnimHostSpawn::seq`]).
armed_seq = Some(clip.seq_index);
clip_info = Some((head.node, head.duration));
armed_seq = Some(head.seq_index);
arm_id = Some(head.anim_id);
}
if let Some(drive) = GlobalSeqDrive::new(&anims.global_bones, &joints) {
commands.entity(root).insert(drive);
@@ -179,6 +225,7 @@ pub(crate) fn spawn_anim_host(
slot,
clip: clip_info,
seq: armed_seq,
anim_id: arm_id,
})
}
@@ -199,14 +246,84 @@ pub(crate) struct DoodadAnimHost {
pub fade: (f32, Vec3),
/// The looping first-sequence graph node + its duration (secs); `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
/// `Time::elapsed_secs` at spawn — the clock origin. A resume seeks the player to
/// `(now − spawned_at) mod duration` and re-seats the [`GlobalSeqDrive`] clock, so phase is a
/// pure function of the shared clock (the ref's arm-time cursor), not of pause history.
/// `Time::elapsed_secs` at spawn — the [`GlobalSeqDrive`]'s clock origin. Global sequences run
/// on the free shared clock with no arm at all, so a resume re-seats them from this and phase
/// stays a pure function of the shared clock, never of pause history.
pub spawned_at: f32,
/// `Time::elapsed_secs` at the current **arm** — the player's clock origin, which is NOT
/// `spawned_at` any more: the variation re-arms every play-window, and a resume must seek to
/// `(now − armed_at) mod duration` or it lands at the phase of a clip this host stopped playing
/// several windows ago.
pub armed_at: f32,
/// When the armed play-window ends, on the shared clock — the reference's `windowHi`
/// (`[model+0xac]`), rewritten by every arm. `now ≥ window_hi` ⇒ re-roll. A host is born with
/// this at `NEG_INFINITY` so the first frame performs the holder's `variationIdx = -1` arm.
pub window_hi: f32,
/// The animation id to re-roll over ([`AnimHostSpawn::anim_id`]); `None` on the gseq-only tier,
/// which has no arm and therefore no window.
pub anim_id: Option<u16>,
/// Whether the host was ticking last frame (edge-triggered pause/resume).
pub active: bool,
}
/// The doodad's self-sustaining re-arm (decision 0768, wow-re `doodad-anim-host.md` §5): when the
/// armed play-window ends, roll a fresh frequency-weighted variation of the same animation id, snap
/// to it, and write the next window. This is the whole of bug B63's residual — the Blasted Lands
/// lightning keys its entire burst in a 5.0 %-weighted variation (`frequency` 1638 of 32767), so
/// with a once-at-load pick ~1.5 of the Tainted Scar's 31 placements strobed from a *fixed* spot
/// every 1.3 s for ever, while the reference re-rolls all 31 every window and the strike wanders.
///
/// Two details that are easy to get wrong, both byte-pinned:
/// - **The re-arm is a snap, not a blend** (`blendFlag = 0` at `0x6951c8`) — hence `stop_all` before
/// the play rather than a cross-fade.
/// - **The window advances on linkage, not on the draw.** The reference's per-frame walk covers
/// every model spliced into the scene list, which the doodad drain does per *in-range* doodad, so
/// a doodad behind the camera keeps cycling. This system therefore runs over ALL hosts and never
/// consults [`DoodadAnimHost::active`] — gating it on the draw instead would freeze the whole
/// field while you looked away and then re-roll all 31 placements on the same frame you turned
/// back, a burst of simultaneous strikes that the reference cannot produce.
///
/// A host that is not currently drawn still re-rolls; it just updates [`DoodadAnimHost::clip`] and
/// leaves the (stopped) player alone, so [`gate_doodad_anim`]'s resume arms whatever the latest
/// window rolled.
fn reroll_doodad_variation(
time: Res<Time>,
mut rng: ResMut<AnimRng>,
mut hosts: Query<(
&mut DoodadAnimHost,
&ModelAnimations,
Option<&mut AnimationPlayer>,
)>,
) {
let now = time.elapsed_secs();
for (mut host, anims, player) in &mut hosts {
let Some(anim_id) = host.anim_id else {
continue; // gseq-only: free-clock loops, never armed (§1)
};
if now < host.window_hi {
continue;
}
let Some(clip) = anims.pick_variation(anim_id, rng.draw()) else {
// No chain for this id (a model whose sequence set changed under us): stop asking.
host.anim_id = None;
continue;
};
let (node, duration) = (clip.node, clip.duration);
let replay = rng.replay_count(clip.replay);
host.armed_at = now;
// `span · R` — for `replay = (0, 0)` that is exactly one loop, so the roll repeats every
// pass. A zero-duration clip would make this a busy loop, so it costs one window minimum.
host.window_hi = now + (duration * replay as f32).max(f32::EPSILON);
host.clip = Some((node, duration));
if host.active {
if let Some(mut p) = player {
p.stop_all(); // the snap
p.play(node).repeat();
}
}
}
}
/// The draw gate: pause a doodad's animation when none of its submeshes is drawn, resume — seeking
/// both clocks to the shared-clock position — when one is again. Runs before [`AnimationSystems`] so
/// a resume's seek lands the same frame. Steady state (nothing flipped) is one `Visibility` read per
@@ -277,7 +394,10 @@ fn gate_doodad_anim(
let anim = p.start(node);
anim.repeat();
if duration > 0.0 {
anim.seek_to(t.rem_euclid(duration));
// Phase from the current ARM, not from spawn: the variation re-arms every
// play-window, so `spawned_at` names a clip this host may have stopped
// playing many windows ago (decision 0768).
anim.seek_to((now - host.armed_at).rem_euclid(duration));
}
}
} else {
@@ -532,7 +652,16 @@ impl Plugin for DoodadAnimPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UvAnimMaterials>();
app.init_resource::<TintAnimMaterials>();
app.add_systems(PostUpdate, gate_doodad_anim.before(AnimationSystems));
app.init_resource::<AnimRng>();
// The re-roll runs BEFORE the draw gate: a window that expires this frame must arm its new
// clip before the gate decides what to resume, or a host re-appearing on the same frame
// resumes the previous window's variation for one frame.
app.add_systems(
PostUpdate,
(reroll_doodad_variation, gate_doodad_anim)
.chain()
.before(AnimationSystems),
);
// Before the visibility authority (`ModelVisSet`): it composes `MatAnim::current` into the
// render-alpha tag the same frame.
app.add_systems(
@@ -751,6 +880,221 @@ mod tests {
));
}
/// The client's `rand()` is the MSVC LCG, and its seed-1 stream is the textbook one. Pinning the
/// first draws keeps the weighted roll on the reference's actual sequence rather than on "some
/// uniform generator" (wow-re `_rand 0x7400e5`, decision 0768).
#[test]
fn the_anim_rng_is_the_reference_msvc_stream() {
let mut rng = AnimRng::default();
let first: Vec<u16> = (0..6).map(|_| rng.draw()).collect();
assert_eq!(first, vec![41, 18467, 6334, 26500, 19169, 15724]);
assert!(first.iter().all(|&v| v <= 0x7fff), "range is [0, 32767]");
}
/// `R = max(1, min + ((rand()·(max−min)) >> 15))`. `(0, 0)` — the overwhelming majority, and the
/// lightning's own value — pins to 1, so the window is exactly one loop and the variation
/// re-rolls every pass.
#[test]
fn the_replay_window_is_one_loop_for_the_common_case() {
let mut rng = AnimRng::default();
for _ in 0..32 {
assert_eq!(rng.replay_count((0, 0)), 1);
}
// A real range stays inside it, and never degenerates to 0 windows.
for _ in 0..64 {
let r = rng.replay_count((2, 5));
assert!((2..=5).contains(&r), "R = {r} outside [2, 5]");
}
}
/// The lightning's own chain, read off the bytes (`benilla-extract m2seq` on
/// `World\Generic\PassiveDoodads\ParticleEmitters\BlastedLandsLightningbolt01.M2`): two
/// variations of animation id 0, weights 31129 / 1638 — so slot 1, which is where all four
/// emitters key their entire burst, carries exactly 5.0 %.
fn lightning_anims() -> ModelAnimations {
let mut a = anims(vec![seq_clip(0, 0, 1), seq_clip(0, 1, 2)], Some(0), false);
a.clips[0].frequency = 31129;
a.clips[0].duration = 1.333;
a.clips[1].frequency = 1638;
a.clips[1].duration = 1.300;
a
}
fn reroll_app() -> App {
// Deliberately NOT MinimalPlugins: `TimePlugin` drives `Time` off the real clock, which
// would clobber the manual advance these tests need to step whole play-windows.
let mut app = App::new();
app.init_resource::<Time>();
app.init_resource::<AnimRng>();
app.add_systems(Update, reroll_doodad_variation);
app
}
fn lightning_host(app: &mut App) -> Entity {
app.world_mut()
.spawn((
DoodadAnimHost {
meshes: Vec::new(),
fade: (1.0, Vec3::ZERO),
clip: None,
spawned_at: 0.0,
armed_at: 0.0,
window_hi: f32::NEG_INFINITY,
anim_id: Some(0),
active: true,
},
lightning_anims(),
AnimationPlayer::default(),
))
.id()
}
/// **Bug B63's residual, and the whole of decision 0768.** A placed doodad re-rolls its
/// frequency-weighted variation every play-window, for ever — it does NOT hold the one it
/// picked at load.
///
/// Under the superseded contract the pick was once-at-load *and* seeded off the placement's
/// world position, so a Tainted Scar bolt that rolled slot 0 was silent for the life of the
/// session and one that rolled slot 1 strobed from that same fixed spot every 1.3 s for ever.
/// The reference re-rolls all 31 placements every window, which is why its strikes wander.
#[test]
fn a_placed_doodad_rerolls_its_variation_every_play_window() {
let mut app = reroll_app();
let host = lightning_host(&mut app);
// Frame 1: born with an expired window ⇒ the holder's `variationIdx = -1` arm lands at once.
app.update();
let armed = |app: &App| {
app.world()
.entity(host)
.get::<DoodadAnimHost>()
.unwrap()
.clip
};
assert!(armed(&app).is_some(), "the first frame arms");
// Step whole windows and record which variation each one landed on.
let mut seen = std::collections::HashMap::new();
for _ in 0..400 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(1400));
app.update();
let h = app.world().entity(host).get::<DoodadAnimHost>().unwrap();
let (node, duration) = h.clip.expect("a window always leaves something armed");
// R = 1 for `replay = (0, 0)`, so the window is exactly the armed clip's own length.
assert!(
(h.window_hi - h.armed_at - duration).abs() < 1e-3,
"window {} != clip duration {duration}",
h.window_hi - h.armed_at
);
*seen.entry(node).or_insert(0u32) += 1;
}
let slot0 = seen.get(&AnimationNodeIndex::new(1)).copied().unwrap_or(0);
let slot1 = seen.get(&AnimationNodeIndex::new(2)).copied().unwrap_or(0);
assert_eq!(slot0 + slot1, 400, "every window arms one of the two");
// The bug was a placement stuck on ONE variation for ever. Both must occur.
assert!(
slot1 > 0,
"the 5 % strike variation never came up in 400 windows — the bolt is stuck again"
);
assert!(slot0 > 0, "the 95 % silent variation never came up");
// A sanity band, not a distribution test — 400 windows off one fixed seed is far too few
// for that (it lands on 10, ~2.3σ under the mean, unremarkable). The weighting itself was
// measured separately over 200 000 draws: **4.9665 %** taken at the real
// two-draws-per-window stride, against the authored 1638/32768 = 4.9988 %. MSVC's LCG shows
// no stride bias here, which is worth having checked: the variation roll and the replay
// count come off the same stream, and a strided LCG is exactly where a rare variation could
// quietly under-fire and leave the field half as active as the reference's.
assert!(
(2..=60).contains(&slot1),
"slot 1 came up {slot1}/400 — far enough off its authored 5 % to suspect the roll"
);
}
/// The gseq-only tier has no arm, so it has no play-window and nothing to re-roll: its channels
/// are free-clock loops the reference drives with zero arming (§1).
#[test]
fn a_gseq_only_host_never_rerolls() {
let mut app = reroll_app();
let host = app
.world_mut()
.spawn((
DoodadAnimHost {
meshes: Vec::new(),
fade: (1.0, Vec3::ZERO),
clip: None,
spawned_at: 0.0,
armed_at: 0.0,
window_hi: f32::NEG_INFINITY,
anim_id: None,
active: true,
},
anims(Vec::new(), None, true),
AnimationPlayer::default(),
))
.id();
for _ in 0..8 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(1400));
app.update();
}
let h = app.world().entity(host).get::<DoodadAnimHost>().unwrap();
assert!(h.clip.is_none(), "nothing was ever armed");
assert_eq!(h.window_hi, f32::NEG_INFINITY, "no window was ever opened");
}
/// A host that is not currently drawn still advances its window — the reference gates the cycle
/// on **linkage** (the in-range doodad drain), not on the draw. Gating on the draw would freeze
/// the field while the camera looks away and then re-roll every placement on the one frame it
/// turns back: a burst of simultaneous strikes the reference cannot produce. The undrawn host
/// keeps its stopped player and just updates what a resume will arm.
#[test]
fn an_undrawn_host_keeps_cycling() {
let mut app = reroll_app();
let host = lightning_host(&mut app);
app.world_mut()
.entity_mut(host)
.get_mut::<DoodadAnimHost>()
.unwrap()
.active = false;
app.update();
let first = app
.world()
.entity(host)
.get::<DoodadAnimHost>()
.unwrap()
.window_hi;
for _ in 0..3 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(1400));
app.update();
}
let h = app.world().entity(host).get::<DoodadAnimHost>().unwrap();
assert!(
h.window_hi > first,
"an undrawn host still opened new windows"
);
assert!(
h.clip.is_some(),
"and still has something for the resume to arm"
);
assert_eq!(
app.world()
.entity(host)
.get::<AnimationPlayer>()
.unwrap()
.playing_animations()
.count(),
0,
"but its stopped player was left alone"
);
}
/// The draw gate stops the player + pauses the gseq drive when every submesh is hidden, and on
/// re-appearing re-arms at the shared-clock position (`(now − spawned_at) mod duration`) — the
/// ref's clock-indexed pose, so pause history never desyncs phase.
@@ -781,6 +1125,11 @@ mod tests {
fade: (1.0, Vec3::ZERO),
clip: Some((node, 2.0)),
spawned_at: 0.0,
armed_at: 0.0,
// Far future: this test exercises the DRAW gate, so no window may expire
// under it and change the armed clip mid-assertion.
window_hi: f32::INFINITY,
anim_id: Some(0),
active: true,
},
player,
+5 -5
View File
@@ -319,11 +319,11 @@ pub(super) fn spawn_wmo_gameobject_props(
// reference re-anchors every cloud to the emitter's live position), while
// an animated bone still never drags it.
Some(root),
// A placed prop: the doodad law — one arm, spawn clock, but at the slot
// that arm's variation roll landed on (decision 0760; pinning slot 0 here
// killed every emitter keyed only in a later variation, silently).
match host.as_ref().and_then(|h| h.seq) {
Some(s) => particles::EmitClock::PinnedSeq(s),
// A placed prop: the doodad law — which is NOT one arm for life. It re-rolls
// its variation every play-window (decision 0768), so the emitter resolves
// the slot + clip time off the host's live player each frame.
match host.as_ref().and_then(|h| h.arm) {
Some(arm) => particles::EmitClock::Host(arm),
None => particles::EmitClock::Pinned,
},
) {
+438
View File
@@ -0,0 +1,438 @@
//! `hover_log.rs` — the **hover-cost recorder** (`WOW_HOVER_LOG`).
//!
//! The director reports dropped frames while hovering bag items and spells. Reproducing that needs
//! a live session and a *hand* on the mouse, which the headless capture harness cannot supply and
//! reading `[ui-cost]` lines off a terminal cannot survive: the interesting frames are a handful of
//! spikes inside thousands of quiet ones. So this records **every frame** to a file, tagged with
//! what the tooltip was showing at the time, and prints a report at exit that ranks the cost
//! *against the tooltip state* — hovering vs not, and which hover.
//!
//! Run: `WOW_HOVER_LOG=1 cargo run --release -p benilla` (writes `target/hover-log.csv`; a path
//! value writes there instead). Hover whatever feels bad, for as long as you like, then quit — the
//! report prints to the terminal and the raw rows stay in the file.
//!
//! Columns are one frame each: the wall frame time and the process-CPU delta (the meter vsync
//! cannot rail — `perf.rs`), the UI pass's own phase split (tick/resolve/measure/extract/diff,
//! μs — the same marks `WOW_UI_COST=1` prints), and the tooltip context (shown, line count, owner
//! frame, first line). A spike with flat UI phases is NOT a UI-pass cost, and that negative is the
//! most valuable thing this can say.
use std::io::Write;
use bevy::prelude::*;
use bevy::time::Real;
use benilla_ui::script::UiScript;
/// One frame's UI-pass phase split, in μs — written by [`crate::ui_script::extract::drive_script`]
/// under the same marks the `[ui-cost]` line prints, read here.
#[derive(Resource, Default, Clone)]
pub struct UiFrameCost {
/// How many FontStrings the layout asked the font engine to shape this frame, and the first
/// few by name — a steady hover that keeps asking is the churn this recorder exists to catch.
pub measured: usize,
pub measured_texts: Vec<String>,
pub tick: u128,
pub resolve: u128,
pub measure: u128,
pub extract: u128,
pub convert: u128,
pub diff: u128,
pub quads: usize,
pub solves: u64,
pub skipped: bool,
}
/// Where to write, from `$WOW_HOVER_LOG`: unset ⇒ off, `1` ⇒ the default path, anything else ⇒
/// that path.
fn log_path() -> Option<String> {
match std::env::var("WOW_HOVER_LOG") {
Err(_) => None,
Ok(v) if v.is_empty() || v == "0" => None,
Ok(v) if v == "1" => Some("target/hover-log.csv".to_string()),
Ok(v) => Some(v),
}
}
/// Is the recorder on? Read once — the phase marks in `drive_script` consult this every frame.
pub fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| log_path().is_some())
}
/// One recorded frame, kept in memory for the exit report (the file gets it either way).
#[derive(Clone)]
struct Row {
frame_ms: f32,
cpu_ms: f32,
cost: UiFrameCost,
/// The tooltip context: `None` = no tooltip up. `(owner frame, line count, first line)`.
tip: Option<(String, i64, String)>,
}
#[derive(Resource)]
struct Recorder {
out: std::io::BufWriter<std::fs::File>,
path: String,
rows: Vec<Row>,
prev_cpu: Option<f64>,
}
pub struct HoverLogPlugin;
impl Plugin for HoverLogPlugin {
fn build(&self, app: &mut App) {
if !enabled() {
return;
}
let path = log_path().expect("enabled() checked");
if let Some(dir) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::File::create(&path) {
Ok(f) => {
let mut out = std::io::BufWriter::new(f);
let _ = writeln!(
out,
"frame_ms,cpu_ms,tick_us,resolve_us,measure_us,extract_us,convert_us,\
diff_us,quads,solves,skipped,measured,measured_texts,tip_owner,tip_lines,\
tip_first_line"
);
info!("hover log: recording every frame to {path}");
app.insert_resource(Recorder {
out,
path,
rows: Vec::with_capacity(64_000),
prev_cpu: None,
})
.add_systems(Last, (record, report_on_exit).chain());
}
Err(e) => error!("hover log: cannot write {path}: {e}"),
}
}
}
/// The tooltip context, asked of the VM itself so it reads the same names the director sees. One
/// tiny chunk per frame, only while recording.
fn tooltip_context(script: &UiScript) -> Option<(String, i64, String)> {
let chunk = r#"
if not GameTooltip or not GameTooltip:IsShown() then return "" end
local owner = GameTooltip:GetOwner()
local name = "(no owner)"
if owner and owner.GetName and owner:GetName() then name = owner:GetName() end
local first = ""
if GameTooltipTextLeft1 and GameTooltipTextLeft1:GetText() then
first = GameTooltipTextLeft1:GetText()
end
return name .. "\t" .. GameTooltip:NumLines() .. "\t" .. first
"#;
let s: String = script.eval(chunk).ok()?;
if s.is_empty() {
return None;
}
let mut parts = s.split('\t');
let owner = parts.next()?.to_string();
let lines = parts.next()?.trim().parse::<f64>().ok()? as i64;
let first = parts.next().unwrap_or("").to_string();
Some((owner, lines, first))
}
/// A CSV cell: quote and escape, and keep it to one line (tooltip text carries `|c` markup).
fn cell(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\"").replace(['\n', '\r'], " "))
}
fn record(
time: Res<Time<Real>>,
cost: Res<UiFrameCost>,
script: Option<NonSend<UiScript>>,
mut rec: ResMut<Recorder>,
) {
let frame_ms = time.delta_secs() * 1000.0;
let cpu_now = crate::perf::process_cpu_secs();
let cpu_ms = match (rec.prev_cpu, cpu_now) {
(Some(prev), Some(now)) => ((now - prev) * 1000.0) as f32,
_ => 0.0,
};
rec.prev_cpu = cpu_now;
// The very first frame's delta is the startup gap, not a frame.
if rec.rows.is_empty() && frame_ms > 100.0 {
return;
}
let tip = script.as_deref().and_then(tooltip_context);
let c = cost.clone();
let (owner, lines, first) = tip
.clone()
.unwrap_or_else(|| (String::new(), 0, String::new()));
let _ = writeln!(
rec.out,
"{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{}",
c.tick,
c.resolve,
c.measure,
c.extract,
c.convert,
c.diff,
c.quads,
c.solves,
u8::from(c.skipped),
c.measured,
cell(&c.measured_texts.join(" ⏎ ")),
cell(&owner),
cell(&first),
);
rec.rows.push(Row {
frame_ms,
cpu_ms,
cost: c,
tip,
});
}
fn pct(sorted: &[f32], p: f32) -> f32 {
if sorted.is_empty() {
return 0.0;
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
let i = ((sorted.len() - 1) as f32 * p).round() as usize;
sorted[i]
}
/// One population's line in the report: how many frames, and what they cost.
fn summarize(label: &str, rows: &[&Row]) -> String {
if rows.is_empty() {
return format!(" {label:<34} (no frames)");
}
let mut wall: Vec<f32> = rows.iter().map(|r| r.frame_ms).collect();
let mut cpu: Vec<f32> = rows.iter().map(|r| r.cpu_ms).collect();
wall.sort_by(f32::total_cmp);
cpu.sort_by(f32::total_cmp);
#[allow(clippy::cast_precision_loss)]
let n = rows.len() as f32;
let ui_us = |f: fn(&UiFrameCost) -> u128| {
rows.iter().map(|r| f(&r.cost) as f64).sum::<f64>() / f64::from(n)
};
// The churn number: mean FontStrings re-shaped per frame. A settled UI sits at 0.00; anything
// above it in a steady hover population is a string whose measure key moves every frame.
let measured = rows.iter().map(|r| r.cost.measured as f64).sum::<f64>() / f64::from(n);
// What the frame actually DID, as opposed to what it cost: how often the layout fixpoint ran
// (a solve walks the whole anchor graph, not the tooltip's corner of it) and how often the
// extract gate skipped the conversion+rasterize loop outright.
#[allow(clippy::cast_precision_loss)]
let solves = rows.iter().map(|r| r.cost.solves as f64).sum::<f64>() / f64::from(n);
let skips = 100.0 * rows.iter().filter(|r| r.cost.skipped).count() as f32 / n;
// Counted against the DROPPED threshold, not the raw budget: synced wall time rails at the
// display's present grant and jitters around it, so counting frames over 16.7 ms mostly counts
// jitter (`perf.rs`'s `DROPPED_FACTOR` — a missed interval is what a player feels).
let dropped = rows
.iter()
.filter(|r| r.frame_ms > crate::perf::FRAME_BUDGET_MS * 1.5)
.count();
format!(
" {label:<34} n={:<6} wall p50={:>6.2} p99={:>6.2} max={:>7.2} cpu p50={:>6.2} \
p99={:>6.2} dropped={:>5.1}%\n ui μs: tick={:>6.0} resolve={:>6.0} \
measure={:>5.0} extract={:>6.0} convert={:>6.0} diff={:>5.0}\n solves/frame={:.2} \
reshaped/frame={:.2} extract-gate-skipped={:.0}%",
rows.len(),
pct(&wall, 0.50),
pct(&wall, 0.99),
wall.last().copied().unwrap_or(0.0),
pct(&cpu, 0.50),
pct(&cpu, 0.99),
100.0 * dropped as f32 / n,
ui_us(|c| c.tick),
ui_us(|c| c.resolve),
ui_us(|c| c.measure),
ui_us(|c| c.extract),
ui_us(|c| c.convert),
ui_us(|c| c.diff),
solves,
measured,
skips,
)
}
/// At quit: the ranked read. Splits the run by tooltip state — that split IS the question, and a
/// per-owner breakdown says whether one hover channel (bag slot vs spell button) is the expensive
/// one. The worst frames print with their tooltip context so a spike is attributable to what was
/// under the cursor at the time.
fn report_on_exit(mut exits: MessageReader<AppExit>, mut rec: ResMut<Recorder>) {
if exits.read().next().is_none() {
return;
}
let _ = rec.out.flush();
let rows: Vec<Row> = rec.rows.clone();
if rows.is_empty() {
info!("hover log: no frames recorded");
return;
}
println!("{}", report(&rows, &rec.path));
}
/// Re-read a recorded CSV and print the report — `WOW_HOVER_LOG_REPORT=<path>`, handled before the
/// app starts (see `main`). The analysis moves faster than the sessions that feed it: a question
/// this report cannot yet answer is one column away, and re-deriving it from rows already on disk
/// costs nobody a second play session.
pub fn report_recorded_file(path: &str) {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("hover log: cannot read {path}: {e}");
return;
}
};
let mut rows = Vec::new();
for line in text.lines().skip(1) {
if let Some(r) = parse_row(line) {
rows.push(r);
}
}
if rows.is_empty() {
eprintln!("hover log: no rows parsed from {path}");
return;
}
println!("{}", report(&rows, path));
}
/// One CSV line back into a [`Row`]. Splits on commas OUTSIDE quotes (the text columns carry
/// commas and doubled quotes — `cell`'s escaping, read back).
fn parse_row(line: &str) -> Option<Row> {
let mut fields: Vec<String> = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if quoted && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => fields.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
fields.push(cur);
if fields.len() < 16 {
return None;
}
let num = |i: usize| fields[i].parse::<f64>().ok();
let owner = fields[13].clone();
Some(Row {
frame_ms: num(0)? as f32,
cpu_ms: num(1)? as f32,
cost: UiFrameCost {
measured: num(11)? as usize,
measured_texts: if fields[12].is_empty() {
Vec::new()
} else {
fields[12].split(" ⏎ ").map(str::to_string).collect()
},
tick: num(2)? as u128,
resolve: num(3)? as u128,
measure: num(4)? as u128,
extract: num(5)? as u128,
convert: num(6)? as u128,
diff: num(7)? as u128,
quads: num(8)? as usize,
solves: num(9)? as u64,
skipped: num(10)? != 0.0,
},
tip: if owner.is_empty() {
None
} else {
Some((owner, num(14)? as i64, fields[15].clone()))
},
})
}
/// The report body, shared by the live exit path and the offline reader.
fn report(rows: &[Row], path: &str) -> String {
let mut out = String::new();
out.push_str("\n==================== HOVER LOG ====================\n");
let all: Vec<&Row> = rows.iter().collect();
let idle: Vec<&Row> = rows.iter().filter(|r| r.tip.is_none()).collect();
let hover: Vec<&Row> = rows.iter().filter(|r| r.tip.is_some()).collect();
out.push_str(&summarize("ALL", &all));
out.push('\n');
out.push_str(&summarize("no tooltip", &idle));
out.push('\n');
out.push_str(&summarize("tooltip up", &hover));
out.push('\n');
// Per hover target, busiest first — one line per owner frame the tooltip was anchored to.
let mut by_owner: std::collections::BTreeMap<String, Vec<&Row>> = Default::default();
for r in &hover {
if let Some((owner, _, _)) = &r.tip {
by_owner.entry(owner.clone()).or_default().push(r);
}
}
let mut owners: Vec<(String, Vec<&Row>)> = by_owner.into_iter().collect();
owners.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
for (owner, rs) in owners.iter().take(8) {
out.push_str(&summarize(&format!(" ↳ {owner}"), rs));
out.push('\n');
}
// The split that separates a STEADY tax from a per-CHANGE one: frames that ran the layout
// fixpoint against frames that did not. A hover whose quiet frames are as expensive as its
// solving ones has a per-frame problem; one where the cost lives entirely in the solving
// frames is paying per content CHANGE — and moving the mouse across a bag grid changes content
// constantly, which is the same felt hitch by a different mechanism and a different fix.
let solved: Vec<&Row> = hover
.iter()
.copied()
.filter(|r| r.cost.solves > 0)
.collect();
let quiet: Vec<&Row> = hover
.iter()
.copied()
.filter(|r| r.cost.solves == 0)
.collect();
out.push_str(&summarize("tooltip up · frames that SOLVED", &solved));
out.push('\n');
out.push_str(&summarize("tooltip up · frames that did NOT", &quiet));
out.push('\n');
// The churn roll-up: which strings the font engine was asked to re-shape, and on how many
// frames. A string appearing on hundreds of frames is a measure key that never settles — the
// per-frame re-shape and the forced second resolve both hang off it, and it is the fix's
// target. Counted per frame it appears on, not per request, so one loud string reads as one
// row.
let mut churn: std::collections::HashMap<String, (usize, usize)> = Default::default();
for r in rows {
for t in &r.cost.measured_texts {
let e = churn.entry(t.clone()).or_default();
e.0 += 1;
e.1 += usize::from(r.tip.is_some());
}
}
let mut churn: Vec<(String, (usize, usize))> = churn.into_iter().collect();
churn.sort_by_key(|(_, (all, _))| std::cmp::Reverse(*all));
if !churn.is_empty() {
out.push_str(" re-shaped strings (frames seen / of those, with a tooltip up):\n");
for (text, (all, hovering)) in churn.iter().take(15) {
out.push_str(&format!(" {all:>6} {hovering:>6} {text:?}\n"));
}
}
// The worst frames, with what was under the cursor.
// Ranked by CPU, not wall: under vsync every wall reading rails at the present interval and
// says nothing (`perf.rs`) — CPU is the cost meter the rail cannot fool.
let mut worst = rows.to_vec();
worst.sort_by(|a, b| b.cpu_ms.total_cmp(&a.cpu_ms));
out.push_str(
" worst frames by CPU (cpu ms / wall ms / ui tick+resolve+convert μs / tooltip):\n",
);
for r in worst.iter().take(12) {
let tip = r.tip.as_ref().map_or_else(
|| "-".to_string(),
|(o, n, f)| format!("{o} [{n} lines] {f}"),
);
out.push_str(&format!(
" {:>8.2} {:>7.2} {:>6}+{:>6}+{:>6} {tip}\n",
r.cpu_ms, r.frame_ms, r.cost.tick, r.cost.resolve, r.cost.convert
));
}
out.push_str(&format!(" rows: {path}\n"));
out.push_str("===================================================\n");
out
}
+11 -1
View File
@@ -436,11 +436,21 @@ pub(super) fn update_time_lighting(
// instrument for comparing our resolved pair against a reference-trace decode (which reports the
// committed GL pair in 0..255 bytes). Throttled on the whole minute so a 60 s probe logs a handful
// of lines, not one per frame (`sun_dir` wobbles continuously).
//
// It leads with the **`map` + eye position it sampled at**, because those are the two inputs that
// decide everything after them and neither is visible from the chair. Every colour below is
// `sample_blended(map, wow_pos, …)`, and `benilla-extract lightblend <map> <x> <y> <z> <minute>`
// recomputes the same call offline — so a line from a live run can be replayed against the DBC
// instead of argued about. Without the map printed, "is this the atmosphere the zone actually
// authored, or the one next door?" costs a session (Stratholme, 2026-07-29).
if *last_dump != Some(minute) && std::env::var_os("WOW_LIGHT_DUMP").is_some() {
*last_dump = Some(minute);
let b = |c: [f32; 3]| [c[0] * 255.0, c[1] * 255.0, c[2] * 255.0].map(|v| v.round() as i32);
eprintln!(
"[light] minute {minute} ({source:?}) ambient {:?} diffuse {:?} spec {:?} sun_dir {:.3} fog {:?} start/end {:.0}/{:.0}",
"[light] map {map} at ({:.1}, {:.1}, {:.1}) minute {minute} ({source:?}) ambient {:?} diffuse {:?} spec {:?} sun_dir {:.3} fog {:?} start/end {:.0}/{:.0}",
wow_pos[0],
wow_pos[1],
wow_pos[2],
b(resolved.ambient),
b(resolved.diffuse),
b(resolved.spec),
+1
View File
@@ -1166,6 +1166,7 @@ mod tests {
fn the_floor_follows_the_placement_transform() {
let bounds = benilla_formats::WmoGroupInfo {
interior: true,
show_skybox: false,
bbox_min: [-10.0, -10.0, 0.0],
bbox_max: [10.0, 10.0, 4.0],
};
+12
View File
@@ -48,6 +48,7 @@ mod glue_strings;
mod go_anim;
mod go_templates;
mod ground_fx;
mod hover_log;
mod interact;
mod interior;
mod items;
@@ -132,6 +133,7 @@ mod water_fx;
mod wdl;
mod weather;
mod wmo_portal;
mod wmo_sky;
mod world_map;
mod world_state;
@@ -215,6 +217,12 @@ const SPAWN_XY: (f32, f32) = (-8949.95, -132.49);
fn main() -> AppExit {
// `WOW_CAPTURE=list` just prints the harness scenario names (the source of truth `scripts/visual.sh`
// reads) and exits before any window/asset setup.
// `WOW_HOVER_LOG_REPORT=<csv>` re-reads a recorded run and prints its report, then exits —
// no window, no game. New analysis lands on runs already captured (see `hover_log`).
if let Ok(path) = std::env::var("WOW_HOVER_LOG_REPORT") {
hover_log::report_recorded_file(&path);
return AppExit::Success;
}
if std::env::var("WOW_CAPTURE").as_deref() == Ok("list") {
capture::print_scenario_names();
return AppExit::Success;
@@ -446,6 +454,7 @@ fn main() -> AppExit {
// Frame-time HUD + diagnostics — the performance standard.
// After DebugPanelPlugin so the egui plugin/context it sets up already exists. Toggle: P.
.add_plugins(PerfPlugin)
.add_plugins(hover_log::HoverLogPlugin)
// Foundation: opens the patch chain + inserts WorldAssets/RenderConfig (AssetSet::Open), which
// every other subsystem's startup runs after.
.add_plugins(AssetPlugin)
@@ -456,6 +465,9 @@ fn main() -> AppExit {
.add_plugins(LightingPlugin)
// Sky dome: the Light.dbc gradient backdrop (camera-centred), driven by the same lighting.
.add_plugins(SkyPlugin)
// WMO skybox: the authored sky a building's `0x40000` group swaps in for that gradient
// (Stratholme's burning city) — registered after SkyPlugin, whose dome it stands down.
.add_plugins(wmo_sky::WmoSkyPlugin)
// Cloud coverage: the reference's procedural field — glare occlusion (occ1) + the visible layer.
.add_plugins(CloudsPlugin)
// Weather: the SMSG_WEATHER state machine driving the storm light-blend + precipitation
+2
View File
@@ -222,6 +222,7 @@ mod tests {
nav(0.0, 3.0, 0.0, 5, 1), // g3 overlaps XY, reachable only through g2
],
fogs: Vec::new(),
skybox: None,
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
@@ -306,6 +307,7 @@ mod tests {
],
group_nav: vec![nav(0.0, 3.0, 0, 1), nav(-303.0, -300.0, 1, 1)],
fogs: Vec::new(),
skybox: None,
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
+6
View File
@@ -588,10 +588,16 @@ pub(crate) enum ClientCommand {
/// Lua `(bagID, slot)` space before sending. `spell_index` is the template block ordinal the
/// server should cast (`ItemInfo::use_spell_index`) — 0 for every item whose on-use spell is
/// its first block, which is nearly all of them.
///
/// `go_target` aims the use at a GameObject. That is the **key-in-a-lock** packet (decision
/// 0769): opening a locked door with a key is "use the key at the door", not a bare cast of the
/// key's spell — and `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot only when the cast
/// carries `m_CastItem`, which only this packet supplies. `None` for every ordinary use.
UseItem {
bag_index: u8,
slot: u8,
spell_index: u8,
go_target: Option<u64>,
},
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an
/// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back
+2 -1
View File
@@ -661,7 +661,8 @@ fn writer_loop(
bag_index,
slot,
spell_index,
} => w.use_item(bag_index, slot, spell_index),
go_target,
} => w.use_item(bag_index, slot, spell_index, go_target),
ClientCommand::AutoEquipItem { bag_index, slot } => {
w.auto_equip_item(bag_index, slot)
}
+18 -4
View File
@@ -117,7 +117,15 @@ impl Plugin for SkyPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<SkyMaterial>::default())
.add_systems(Startup, setup_sky.after(AssetSet::Open))
.add_systems(Update, (update_sky_colors, apply_sky_visibility))
.add_systems(
Update,
(
update_sky_colors,
// The dome stands down for a WMO skybox, so its gate must read the SETTLED
// resolve, not whichever side of it the executor picked (`crate::wmo_sky`).
apply_sky_visibility.after(crate::wmo_sky::WmoSkyResolve),
),
)
// Camera-anchored placement runs post-propagation (the BillboardPlace slot) off the
// camera's SAME-frame pose — the Update wiring read the last-frame camera (decision
// 0504; sub-visible at the dome's far radius, but it's the same latent bug the near
@@ -234,13 +242,19 @@ fn follow_camera(
*gt = GlobalTransform::from(*tf);
}
/// Hide/show the dome from the debug panel's "disable sky dome" toggle. With the dome hidden the
/// Hide/show the dome from the debug panel's "disable sky dome" toggle — and stand it down entirely
/// while a **WMO skybox** owns the backdrop ([`crate::wmo_sky`]): a building whose group asks for its
/// MOSB model replaces this gradient, it does not layer over it. With the dome hidden the
/// `ClearColor` backdrop (the row-7 fog colour, or black via "black backdrop") shows through.
fn apply_sky_visibility(debug: Res<DebugState>, mut dome: Query<&mut Visibility, With<Sky>>) {
fn apply_sky_visibility(
debug: Res<DebugState>,
wmo_skybox: Res<crate::wmo_sky::CameraWmoSkybox>,
mut dome: Query<&mut Visibility, With<Sky>>,
) {
let Ok(mut vis) = dome.single_mut() else {
return;
};
let want = if debug.lighting.disable_sky_dome {
let want = if debug.lighting.disable_sky_dome || wmo_skybox.0.is_some() {
Visibility::Hidden
} else {
Visibility::Visible
+8
View File
@@ -73,6 +73,10 @@
/// Stars — the first celestial draw (`0x6d4a3f`): everything else in the sky paints over them, so
/// theirs is the ladder's LOWEST rung (see the sign law above).
///
/// The WMO skybox ([`crate::wmo_sky`]) takes no rung: like the gradient dome it replaces, it is an
/// **opaque** backdrop, so it draws in the opaque pass and needs no transparent-sort bias — its
/// forced far depth (the depth law below) is the whole of its ordering, and the two never coexist.
pub(crate) const STARS_BIAS: f32 = -1.0e6;
/// The sun disc — second (`0x7e5b90` via `0x6d4a47`).
pub(crate) const SUN_DISC_BIAS: f32 = -8.2e5;
@@ -117,6 +121,10 @@ fn every_sky_shader_forces_the_far_depth() {
"celestial.wgsl",
include_str!("../assets/shaders/celestial.wgsl"),
),
(
"wmo_skybox.wgsl",
include_str!("../assets/shaders/wmo_skybox.wgsl"),
),
] {
assert!(
src.contains("const SKY_FAR_DEPTH: f32 = 0.0;"),
+69 -14
View File
@@ -218,7 +218,7 @@ pub(super) fn act_on_right_click(
&net,
) {
GoAction::Use if cursor.unable => {}
GoAction::OpenLock(_) if cursor.unable => {}
GoAction::OpenLock(_) | GoAction::OpenByKey { .. } if cursor.unable => {}
GoAction::Use => {
debug!("right-click gameobject use: {guid:#x}");
let _ = net.0.send(ClientCommand::GameObjUse { guid });
@@ -247,6 +247,24 @@ pub(super) fn act_on_right_click(
go_guid: guid,
});
}
GoAction::OpenByKey {
bag_index,
slot,
spell_index,
} => {
// No reagent/totem pre-check here, unlike the skill arm above: that gate is
// about a Mining cast without a pick, and a key has no reagents — the ref's
// `0x6e4000` would pass every key trivially.
debug!(
"right-click gameobject open-by-key: use item ({bag_index},{slot}) blk {spell_index} at {guid:#x}"
);
let _ = net.0.send(ClientCommand::UseItem {
bag_index,
slot,
spell_index,
go_target: Some(guid),
});
}
GoAction::Refuse(err) => {
// `None` is a case the ref is silent on too (a key-item record miss —
// its ask-once query is away — or the deferred key-in-hand open).
@@ -377,9 +395,19 @@ enum GoAction {
/// No lock (or no lock data): `CMSG_GAMEOBJ_USE` — door / lever / quest object / mailbox /
/// unlocked chest.
Use,
/// A lock we can open: cast this spell at the object — a known `OPEN_LOCK` (chest / vein / herb
/// / a picked lock), or a carried key item's own ON_USE spell.
/// A lock we can open with a **known skill spell**: cast it at the object — a known
/// `OPEN_LOCK` (chest / vein / herb / a picked lock). `CMSG_CAST_SPELL`.
OpenLock(u32),
/// A lock whose **KEY** slot we satisfy: *use the key at the object* — `CMSG_USE_ITEM` with the
/// key's wire position and `TARGET_FLAG_GAMEOBJECT`, NOT a bare cast of the key's spell
/// (decision 0769; wow-re `cursor-system.md` §8.4 — "the client never sends a bare
/// CMSG_CAST_SPELL for a key lock"). The distinction is the whole ballgame: `Spell::CanOpenLock`
/// honours a `Lock.dbc` KEY slot only when `m_CastItem` is set, which only USE_ITEM supplies.
OpenByKey {
bag_index: u8,
slot: u8,
spell_index: u8,
},
/// A lock present that we cannot open — the client-local refusal (§8.4: `DisplayError`, **no
/// packet**). `Some` = the red toast to queue; `None` = the ref is silent for this case too.
Refuse(Option<crate::ui_action::UiError>),
@@ -403,13 +431,17 @@ enum KeyFact {
///
/// The satisfaction decision itself lives in [`super::lock::resolve_lock`] so the cursor's `usable`
/// asks exactly the same question (§4a/§8.7 — the icon and the click agree by construction). Here
/// we only turn its answer into a packet: a satisfied **skill** slot casts the matched opener; a
/// satisfied **key** slot casts that key item's ON_USE spell (the reference's `0x5d8c80` → `CastSpell`
/// path — note that vmangos's `CanOpenLock` only honours a key when the cast carries `m_CastItem`,
/// i.e. came in as `CMSG_USE_ITEM`, so on this server the faithful cast is answered with a refusal
/// rather than an open; that gap is the server's, and it is better than the silent dead click the
/// deferral used to give a player holding the right key); an unmet lock takes §8.8's toast routing
/// and sends nothing.
/// we only turn its answer into a packet, and the lock split has **three** arms, not two (wow-re
/// `cursor-system.md` §8.4, VERIFIED): lockless → `CMSG_GAMEOBJ_USE`; a satisfied **skill** slot →
/// `CMSG_CAST_SPELL` of the matched opener; a satisfied **key** slot → `CMSG_USE_ITEM` carrying the
/// key's position and the GO as its cast target ([`GoAction::OpenByKey`]). An unmet lock takes
/// §8.8's toast routing and sends nothing.
///
/// The key arm sent a bare cast until decision 0769, which is why keys never opened anything: the
/// server honours a KEY slot only when the cast carries `m_CastItem` (`Spell::CanOpenLock`,
/// `Spell.cpp:7892`), and only `CMSG_USE_ITEM` supplies it. That was recorded here as the server's
/// gap; it was ours, and wow-re's note says so in the same breath — "the client never sends a bare
/// CMSG_CAST_SPELL for a key lock".
fn resolve_go_action(
guid: u64,
inputs: &mut GoLockInputs,
@@ -468,14 +500,37 @@ fn resolve_go_action(
));
}
};
// A key we carry: cast its ON_USE spell at the object (`0x5f85aa`). The template is ask-once —
// a miss queries and does nothing this click, exactly like the toast's own name miss.
// A key we carry: **use it at the object**. The resolver `0x5f85aa` only hands the caller the
// key's ON_USE spell id AND the item object itself; it is the sender `0x6e54f0` that then
// discriminates on cast-item-vs-caster and takes the item arm — `0x6e57d8 push 0xab`,
// CMSG_USE_ITEM carrying `u8 bag · u8 slot · u8 spellSlot · SpellCastTargets(GO)`, with no raw
// spell id at all (the server re-resolves the Item* from bag+slot). Decision 0769.
//
// So what the wire needs is the key's POSITION, which the same walker that found it gives us.
let Some(store) = me_store else {
return GoAction::Refuse(None);
};
let Some((bag_index, slot, _)) = crate::ui_items::find_item(
&store.0,
&inputs.items,
key_entry,
crate::ui_items::ItemSearch::default(),
) else {
// Held a moment ago (the resolver said so) and gone now — nothing to send.
return GoAction::Refuse(None);
};
// The template is ask-once — a miss queries and does nothing this click, exactly like the
// toast's own name miss. `use_spell_index` is the BLOCK ordinal, the packet's third byte.
match inputs
.items
.template(key_entry, 0, net)
.and_then(|i| i.use_spell.as_ref().map(|s| s.spell_id))
.and_then(|i| i.use_spell_index())
{
Some(spell_id) => GoAction::OpenLock(spell_id),
Some(spell_index) => GoAction::OpenByKey {
bag_index,
slot,
spell_index,
},
None => GoAction::Refuse(None),
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ const MELEE_FLOOR: f32 = 5.0;
/// `GAMEOBJECT_TYPE_GENERIC` (vmangos `SharedDefines.h`) — world decoration whose highlightable
/// predicate is constant-false, so it never shows an interact cursor (wow-re cursor-system §4a).
const GO_TYPE_GENERIC: i32 = 5;
pub(crate) const GO_TYPE_GENERIC: i32 = 5;
/// The transport family — TRANSPORT(11), MAP_OBJECT(14), MO_TRANSPORT(15): their strategy vtables'
/// highlightable slot (+0x14) is constant-false too (`32 c0 c3` — vtable dump from the 5875 binary:
/// `0x80ba58+0x14`→`0x5f5c70`, `0x80b710`/`0x80b798+0x14`→`0x5f48b0`), so a boat / zeppelin /
+24 -28
View File
@@ -39,32 +39,34 @@ use crate::net::ObjectStore;
/// item-template cache (key-item names for the "Requires \<key\>" toast, and the key's own ON_USE
/// spell). The `Option` members are absent without client data.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct GoLockInputs<'w> {
pub(super) templates: Res<'w, crate::go_templates::GameObjectTemplates>,
pub(super) locks: Option<Res<'w, crate::go_templates::Locks>>,
pub(super) lock_types: Option<Res<'w, crate::go_templates::LockTypes>>,
pub(super) spells: Option<Res<'w, crate::ui_action::Spells>>,
pub(super) items: ResMut<'w, crate::items::Items>,
pub(crate) struct GoLockInputs<'w> {
// ResMut, not Res: the tooltip arm of this bundle drives the ask-once template
// request on a miss (the click arm only reads).
pub(crate) templates: ResMut<'w, crate::go_templates::GameObjectTemplates>,
pub(crate) locks: Option<Res<'w, crate::go_templates::Locks>>,
pub(crate) lock_types: Option<Res<'w, crate::go_templates::LockTypes>>,
pub(crate) spells: Option<Res<'w, crate::ui_action::Spells>>,
pub(crate) items: ResMut<'w, crate::items::Items>,
}
/// The GameObject facts the Action gate and the requirement fallback read off the wire — gathered
/// once by the caller so the resolver stays pure.
#[derive(Clone, Copy, Debug)]
pub(super) struct GoFacts {
pub(crate) struct GoFacts {
/// The client's stored `GAMEOBJECT_STATE` (`go+0x27c`) — [`crate::go_anim::go_state`].
pub(super) state: u32,
pub(crate) state: u32,
/// `GAMEOBJECT_FLAGS & GO_FLAG_LOCKED (0x2)`.
pub(super) flag_locked: bool,
pub(crate) flag_locked: bool,
/// `GAMEOBJECT_LEVEL` — the `Skill[i] == 0` requirement fallback's `× 5` base. vmangos leaves
/// it 0 on everything but transports, so on this server the fallback resolves to "no
/// requirement" and the server does the real gating; the client law is modelled regardless.
pub(super) level: u32,
pub(crate) level: u32,
}
/// What the resolver says about a GameObject's lock — the reference's return plus its spell-id
/// out-param, made explicit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum LockOutcome {
pub(crate) enum LockOutcome {
/// No `Lock.dbc` row, or every slot empty — `0x5f8180` null / `[ebp-1]` never set. The object
/// opens by `CMSG_GAMEOBJ_USE`.
Unlocked,
@@ -90,7 +92,7 @@ impl LockOutcome {
/// Read the Action gate's inputs off a hovered GameObject — its store plus the stored state the
/// caller already resolved ([`crate::go_anim::go_state`]). `None` (nothing hovered / no store yet)
/// answers with the wire defaults, which make every gated slot inapplicable rather than free.
pub(super) fn go_facts(go: Option<(&ObjectStore, u32)>) -> GoFacts {
pub(crate) fn go_facts(go: Option<(&ObjectStore, u32)>) -> GoFacts {
match go {
Some((store, state)) => GoFacts {
state,
@@ -131,7 +133,7 @@ pub(crate) const GO_FLAG_LOCKED: u32 = 0x2;
/// `known` is a set. That only decides *which* of several equally-matching spells lands in
/// `matched_spell` (a miner knows four Mining ranks, all `LockType 3`), never the outcome — the
/// scan keeps going past an insufficient one, so the best-valued opener still wins.
pub(super) fn resolve_lock(
pub(crate) fn resolve_lock(
slots: &[LockSlot],
known: &HashSet<u32>,
spells: Option<&crate::ui_action::Spells>,
@@ -189,7 +191,7 @@ pub(super) fn resolve_lock(
/// (`0x5f84be..0x5f84ca` — the resolver; `0x5f3490..0x5f349f` recomputes the same value for the
/// `0xe0` toast's `%d`). A zero `Skill` is *not* "no requirement": every gathering node in the game
/// stores 0 there and leans on the object's level.
pub(super) fn required_skill(slot: &LockSlot, go_level: u32) -> i32 {
pub(crate) fn required_skill(slot: &LockSlot, go_level: u32) -> i32 {
if slot.skill != 0 {
slot.skill as i32
} else {
@@ -197,25 +199,19 @@ pub(super) fn required_skill(slot: &LockSlot, go_level: u32) -> i32 {
}
}
/// Whether we carry item `entry` — the key-slot scan's inventory walk (`0x622270` over the
/// inventory manager). Bags **and the keyring**, which is where dungeon keys actually live; the
/// bank is not walked because benilla only streams its contents while the bank window is open.
/// Whether we carry item `entry` — the key-slot scan's inventory walk. This IS the reference's own
/// walker (`0x622270` → `0x622420`, mode 0 ⇒ `0x47` = equipment | bags | backpack | keyring), which
/// benilla already transcribes once as [`crate::ui_items::find_item`]; it used to be a second,
/// hand-rolled walk here that missed the equipment slots and read 32 keyring slots where the wire
/// has 16. One walker, one answer — and the caller needs the POSITION it returns anyway, to address
/// `CMSG_USE_ITEM` (decision 0769).
fn holds_item(
store: &benilla_protocol::messages::ObjectFields,
items: &crate::items::Items,
entry: u32,
) -> bool {
if crate::ui_items::count_of(store, items, entry) > 0 {
return true;
}
(0..32).any(|i| {
store
.player_keyring_slot(i)
.filter(|&g| g != 0)
.and_then(|g| items.object(g))
.and_then(|f| f.object_entry())
== Some(entry)
})
crate::ui_items::find_item(store, items, entry, crate::ui_items::ItemSearch::default())
.is_some()
}
#[cfg(test)]
+2 -2
View File
@@ -49,11 +49,11 @@ mod cursor_mode;
mod flash;
mod highlight;
mod hover;
mod lock;
pub(crate) mod lock;
mod ring;
mod scan;
pub(crate) use cursor_mode::{CursorKind, WorldCursor, SERVICE_RANGE_SQ};
pub(crate) use cursor_mode::{CursorKind, WorldCursor, GO_TYPE_GENERIC, SERVICE_RANGE_SQ};
/// `GO_FLAG_LOCKED` — the wire bit the lock chain and the GO tooltip's "Locked" line both read.
pub(crate) use lock::GO_FLAG_LOCKED;
// This frame's combat-flash verdict — read by the ring's material pick (in `ring`) and by the
@@ -19,14 +19,19 @@ use crate::model_render::{model_material, MaterialCache, ShadeSel};
use crate::terrain::WowModelMaterial;
/// What one placement's animation host armed, for the consumers that spawn alongside its submeshes.
/// Both fields are *per placement*, not per model: the joint set is this instance's, and `seq` is
/// the slot this instance's own frequency-weighted variation roll landed on.
/// Both fields are *per placement*, not per model: the joint set is this instance's, and `arm` is
/// this instance's own anim root, whose live player names the sequence it is currently playing.
pub(crate) struct PlacementHost {
/// Bone-indexed joint entities — an emitter or ribbon rides its host bone's joint off this.
pub joints: Vec<Entity>,
/// The armed FILE sequence slot ([`crate::doodad_anim::AnimHostSpawn::seq`]); `None` on the
/// gseq-only tier, where nothing was armed and slot 0 is the honest answer.
pub seq: Option<usize>,
/// The anim-root entity IF this placement armed a sequence; `None` on the gseq-only tier, where
/// nothing was armed and slot 0 is the honest answer.
///
/// This used to be the armed slot as a plain `usize`, captured once at spawn. That was right
/// only while a placed doodad kept one variation for life — it re-rolls every play-window
/// (decision 0768), so a captured slot goes stale within about a second and the consumer has to
/// resolve against the live player instead.
pub arm: Option<Entity>,
}
/// Spawn a model's submeshes at `transform` with the full doodad/WMO component set. Returns the spawned
@@ -333,21 +338,21 @@ pub(crate) fn spawn_model_entities(
// Arm the draw gate: animation runs iff any of these submeshes is drawn (`doodad_anim`) —
// or, for a meshless (particles-only) host, iff the placement's fade sphere is in the draw
// set (the same 0171 law its emitters gate on).
let armed_seq = host.as_ref().and_then(|h| h.seq);
let arm = host.as_ref().and_then(|h| h.seq.map(|_| h.root));
if let Some(h) = host {
let now = m2.map(|(_, now)| now).unwrap_or_default();
commands.entity(h.root).insert(DoodadAnimHost {
meshes: skinned_meshes,
fade: (radius, transform.transform_point(local_center)),
clip: h.clip,
spawned_at: m2.map(|(_, now)| now).unwrap_or_default(),
spawned_at: now,
armed_at: now,
// Born already expired, so the first frame runs the holder setup's `variationIdx = -1`
// arm over the loader's var-0 seed — the reference's own two-stage load (decision 0768).
window_hi: f32::NEG_INFINITY,
anim_id: h.anim_id,
active: true,
});
}
(
out,
skin.map(|(joints, _)| PlacementHost {
joints,
seq: armed_seq,
}),
)
(out, skin.map(|(joints, _)| PlacementHost { joints, arm }))
}
@@ -127,7 +127,7 @@ pub(super) fn spawn_emitters_for(
emitters: &[ModelEmitter],
transform: Transform,
joints: Option<&[Entity]>,
armed_seq: Option<usize>,
arm: Option<Entity>,
fade: (f32, Vec3),
out: &mut Vec<Entity>,
) {
@@ -149,10 +149,13 @@ pub(super) fn spawn_emitters_for(
owner,
None, // placed doodads/props are never attached models
None, // anchor at the placement: an animated bone never drags the risen cloud
// A placed doodad's one-time arm, on the spawn clock — but at the slot the arm's
// variation roll actually landed on, not slot 0. No host: the arm never changes.
match armed_seq {
Some(s) => particles::EmitClock::PinnedSeq(s),
// A placed doodad's arm is NOT one-time: it re-rolls its variation every play-window
// (decision 0768), so the emitters read the slot AND the clip time off the host's live
// player each frame — the same lane units and GameObjects use, and what the reference's
// `m2_animate` does for every model without distinction. A pinned slot was right only
// under the superseded "armed once at load" contract.
match arm {
Some(root) => particles::EmitClock::Host(root),
None => particles::EmitClock::Pinned,
},
) {
@@ -149,7 +149,7 @@ pub(super) fn spawn_loaded_placements(
&m.emitters,
p.transform,
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.seq),
host.as_ref().and_then(|h| h.arm),
(radius, center),
&mut ents,
);
@@ -460,7 +460,7 @@ pub(super) fn spawn_loaded_placements(
&m.emitters,
d.transform,
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.seq),
host.as_ref().and_then(|h| h.arm),
(radius, center),
&mut ents,
);
+135 -7
View File
@@ -16,8 +16,9 @@ use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use super::equip_error::equip_error_key;
use super::{
find_equip_slot, quality_color, slot_guid_count, EquipErrors, BAGS, BAG_SLOT_FIRST, BANK_BAGS,
BANK_BAG_ID_FIRST, BANK_CONTAINER, BANK_SLOTS, PACK_SLOTS,
find_equip_slot, has_key, keyring_size, quality_color, slot_guid_count, EquipErrors, BAGS,
BAG_SLOT_FIRST, BANK_BAGS, BANK_BAG_ID_FIRST, BANK_CONTAINER, BANK_SLOTS, KEYRING_CONTAINER,
KEYRING_SLOTS, PACK_SLOTS,
};
/// The feed's memory of what it last pushed, for per-bag change events. No cooldown-churn gate:
@@ -25,11 +26,25 @@ use super::{
#[derive(Default)]
pub(super) struct FeedMemory {
pushed: HashMap<i64, ContainerState>,
/// The last `HasKey()` pushed — kept only so the transition can be logged once instead of
/// every frame. It is the gate the entire keyring UI hangs off, and "the keyring never
/// appeared" is otherwise indistinguishable from "the button is mis-anchored".
had_key: bool,
}
/// A spell's tooltip text: the $-substituted description (the real "Use: Restores 392 to 653
/// health over 21 sec" shape), falling back to the bare name while it has none. Shared by the
/// item trigger lines and the set-bonus lines.
/// health over 21 sec" shape). Shared by the item trigger lines and the set-bonus lines.
///
/// **An empty description yields `None`, and the caller drops the whole line** — byte-verified at
/// the item builder `0x52d8a0`: it runs the $-expander into a buffer (`0x52da24` → `0x5075f0`),
/// tests the FIRST BYTE of the result (`0x52da29 mov al,[ebp-0x4f0]` / `0x52da2f test al,al`) and
/// on zero **jumps clean past the entire trigger block** (`0x52da31 je 0x52dd3d`) — past the
/// ONUSE/ONEQUIP/ONPROC prefix select and the `"%s %s"` join alike. No prefix, no line.
///
/// This used to fall back to the spell's bare NAME, which is where "Use: Opening" on a dungeon key
/// came from (director-reported): `Opening` (3365/3366/6247/6477) carries no description in
/// Spell.dbc, so the real client prints nothing and we printed the name. The fallback was an
/// invention, not a transcription — no such branch exists in the builder.
fn spell_desc_text(
spells: Option<&crate::ui_action::Spells>,
id: u32,
@@ -38,6 +53,8 @@ fn spell_desc_text(
let sp = spells?;
let d = sp.catalog.get(id)?;
match d.description.as_deref().filter(|t| !t.is_empty()) {
// The $-expander can still yield an empty string from a non-empty template; the builder's
// test is on the EXPANDED text, so ours is too.
Some(desc) => {
let ctx = benilla_formats::TokenContext {
durations: &sp.durations,
@@ -45,9 +62,9 @@ fn spell_desc_text(
lookup: &|i| sp.catalog.get(i),
home_area,
};
Some(benilla_formats::substitute(desc, d, &ctx))
Some(benilla_formats::substitute(desc, d, &ctx)).filter(|t| !t.is_empty())
}
None => Some(d.name.clone()),
None => None,
}
}
@@ -697,6 +714,54 @@ pub(super) fn feed_containers(
},
);
}
// The keyring (decision 0765): container −2, the player array's slots 81.., no container
// object of its own — structurally the bank's twin. Its capacity is NOT a wire field: both
// the reference (`GetKeyRingSize`) and the server (`GetMaxKeyringSize`) derive it from the
// player's level with the same ladder, so [`keyring_size`] computes it here and Lua's
// `GetKeyRingSize()` reads it back off this snapshot. Slots past that count are never fed
// (they can't hold anything — the server refuses to store there), so the window's own
// physIndex-past-size branch hides exactly the right buttons with no keyring-specific code.
let size = keyring_size(store.0.unit_level().unwrap_or(1));
let mut slots = HashMap::new();
for i in 0..size.min(u32::from(KEYRING_SLOTS)) as u8 {
let guid = store.0.player_keyring_slot(i).unwrap_or(0);
if let Some(mut slot) = resolve_slot(
guid,
&mut items,
icons.as_deref(),
&commands,
&cooldowns,
spell_catalog,
&mut names,
now,
ui_now,
) {
slot.locked = pending.contains(KEYRING_CONTAINER, u32::from(i) + 1);
slots.insert(u32::from(i) + 1, slot);
}
}
fresh.insert(
KEYRING_CONTAINER,
ContainerState {
name: Some("Keyring".into()),
num_slots: size,
slots,
},
);
// `HasKey()` — the gate that decides whether the keyring exists in the UI at all. Pushed
// beside the containers because it is the same knowledge (item templates) read over the
// same slot arrays, and it must be fresh on exactly the frames a BAG_UPDATE fires.
let key = has_key(&store.0, &mut items, &commands);
if key != memory.had_key {
debug!(
"ui_items: HasKey() -> {key} (keyring {})",
if key { "shown" } else { "hidden" }
);
memory.had_key = key;
}
script.set_has_key(key);
}
// Diff whole bags; push + fire BAG_UPDATE per transition, one BAG_UPDATE_DELAYED per batch. A
@@ -715,7 +780,17 @@ pub(super) fn feed_containers(
for &bag in &changed {
script.set_container(bag, fresh.get(&bag).cloned());
}
debug!("ui_items: fed {} changed bag(s)", changed.len());
// Name the bags, not just the count: "3 changed" can't tell you WHICH container moved, and
// the negative ids (−1 bank, −2 keyring) are exactly the ones you go looking for.
debug!(
"ui_items: fed {} changed bag(s) — {}",
changed.len(),
changed
.iter()
.map(|b| format!("{b}:{}", fresh.get(b).map_or(0, |c| c.slots.len())))
.collect::<Vec<_>>()
.join(" ")
);
for &bag in &changed {
// The vault fires the reference's own event, per changed slot with the slot id
// (`PLAYERBANKSLOTS_CHANGED(slot)` — BankFrame repaints the one button); everything
@@ -787,4 +862,57 @@ mod tests {
// The election walks slots: a leading sentinel slot doesn't mask a later pool.
assert_eq!(charges_count(&[slot(433, -1), slot(4057, -10)]), 10);
}
/// The trigger line's **empty-description law**, byte-verified at the item builder
/// (`0x52da29`-`0x52da31`: test the expanded text's first byte, jump past the whole block on
/// zero) — checked against the REAL 5875 Spell.dbc, because the whole point is what the actual
/// data holds.
///
/// The director's report: a dungeon key's tooltip read "Use: Opening". `Opening` is a real
/// spell with a real name and NO description, and the old code fell back to the name.
#[test]
fn an_undescribed_spell_prints_no_trigger_line_on_real_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = crate::ui_action::Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain)
.expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain)
.expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain)
.expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
// Every "Opening"/"Closing" the lock chain can reach — the key spells (3365/3366/6247/6477
// are all literally named "Opening") carry no description, so NO Use: line may be built.
for id in [3365u32, 3366, 6246, 6247, 6477, 21651] {
let d = spells.catalog.get(id).expect("a real Spell.dbc row");
assert!(
!d.name.is_empty(),
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
);
assert_eq!(
super::spell_desc_text(Some(&spells), id, None),
None,
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
d.name
);
}
// The control: a described spell still produces its line, so this is a law about EMPTY
// descriptions and not a blanket mute. Fireball (133) is the tooltip suite's own anchor.
let fireball = super::spell_desc_text(Some(&spells), 133, None)
.expect("a described spell still yields its line");
assert!(
fireball.contains("damage"),
"expected the substituted Fireball description, got {fireball:?}"
);
}
}
+254 -10
View File
@@ -3,7 +3,8 @@
//!
//! Each frame, the player's own descriptor names the bag layout (the PRIVATE `PACK_SLOT` array =
//! the backpack; `INV_SLOT` 19–22 = the equipped bags, each a container object with its own
//! `CONTAINER_FIELD_SLOT` array), the item store ([`crate::items::Items`]) resolves slot guids to
//! `CONTAINER_FIELD_SLOT` array; `KEYRING_SLOT` 81.. = the keyring, decision 0765 — a container
//! with no container *object*, exactly like the bank), the item store ([`crate::items::Items`]) resolves slot guids to
//! instances (entry, stack count), the template cache resolves entries to name/quality (ask-once
//! `ITEM_QUERY_SINGLE` — a slot whose answer is in flight shows as an unresolved occupied slot and
//! fills in when it lands), and `ItemDisplayInfo.dbc` turns the template's display id into the
@@ -41,7 +42,7 @@ use benilla_ui::script::EQUIPMENT_BAG;
use bevy::prelude::*;
use crate::items::Items;
use crate::net::ObjectStore;
use crate::net::{NetCommands, ObjectStore};
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
@@ -80,6 +81,40 @@ pub(crate) const BANK_CONTAINER: i64 = -1;
/// The first bank-bag live-API container id (bank bags are containers 5..=10, the reference id
/// space: `NUM_BAG_SLOTS + 1 ..`).
pub(crate) const BANK_BAG_ID_FIRST: i64 = 5;
/// The keyring's live-API container id (`KEYRING_CONTAINER`, the reference
/// `MainMenuBarBagButtons.lua:1`; decision 0765).
pub(crate) const KEYRING_CONTAINER: i64 = -2;
/// The first keyring slot in the player array (vmangos `KEYRING_SLOT_START`).
pub(super) const KEYRING_SLOT_FIRST: u8 = 81;
/// Addressable keyring positions on this wire — vmangos `KEYRING_SLOT_END 97`, i.e. slots 81..96.
/// The descriptor *array* is 32 guids wide and the client's inventory walker scans all 32 (81–112,
/// `player_keyring_slot`'s note), but 97.. is not a valid position: the server's own enum comment
/// is "32 slots (only 16 are visible/accessible in UI)". How many of the 16 a player may actually
/// use is level-gated — [`keyring_size`].
pub(super) const KEYRING_SLOTS: u8 = 16;
/// `BagFamily` 9 = `BAG_FAMILY_KEYS` — the enum value that makes an item a *key*: what the server
/// routes into the keyring (`Player::_CanStoreItem`'s `pProto->BagFamily == BAG_FAMILY_KEYS` arm)
/// and what the reference's `HasKey` searches for ([`has_key`]).
pub(super) const BAG_FAMILY_KEYS: u32 = 9;
/// How many keyring slots a level-`level` player may use — the reference's own `GetKeyRingSize`
/// (`ContainerFrame.lua:773`), which the server enforces with the identical ladder
/// (`Player::GetMaxKeyringSize`, `Player.h:985`): **4** below 40, **8** at 40, **12** at 50,
/// **16** above 60 — that last rung is unreachable at 1.12's level cap of 60, and is transcribed
/// only because both the client and the server carry it. Both sides agreeing on the ladder is why
/// benilla can compute this rather than being told it.
///
/// The reference recomputes it in Lua from `UnitLevel("player")` at every use; benilla computes it
/// once here, feeds it as the keyring container's `num_slots`, and lets Lua's `GetKeyRingSize()`
/// read that back — one formula, one place, the same number.
pub(crate) fn keyring_size(level: u32) -> u32 {
match level {
61.. => 16,
50..=60 => 12,
40..=49 => 8,
_ => 4,
}
}
/// Lua (bag, 1-based slot) → wire `(bag_index, slot)` — the one mapping every drain shares
/// (uses/moves/splits/destroys/autoequips, decision 0216 §6, extended to [`EQUIPMENT_BAG`] by
@@ -97,7 +132,14 @@ pub(crate) const BANK_BAG_ID_FIRST: i64 = 5;
/// [`BANK_CONTAINER`] (the 24 generic slots) → `(255, 39..62)`; bank bags 5..=10 → the bag's own
/// player-array slot 63..68 as the wire bag byte (exactly the equipped-bag rule); and the doll
/// space grows the bank-bag *buttons* as live ids 64..69 (the same "live id − 1 = wire slot" law,
/// so dragging a bag onto a bank bag slot routes through the existing swap drain unchanged).
/// so dragging a bag onto a bank bag slot routes through the existing swap drain unchanged). The
/// **keyring** ([`KEYRING_CONTAINER`], decision 0765) is the plainest case of all: its slots ARE
/// player-array slots ([`KEYRING_SLOT_FIRST`] + the 0-based slot), so every keyring move lands on
/// [`BAG_PLAYER_INVENTORY`] and rides the existing `CMSG_SWAP_INV_ITEM` branch to/from the
/// backpack and the doll, or `CMSG_SWAP_ITEM` when the other end is an equipped bag — no drain
/// changed for it. Ranged at the wire's [`KEYRING_SLOTS`] (81..96), NOT the level-gated
/// [`keyring_size`]: a click past the unlocked count is the server's refusal to give, not ours to
/// pre-empt (and the window never draws those slots anyway).
/// `None` for `slot1 == 0` or a slot past the bag's/doll's range.
pub(crate) fn wire_pos(bag: i64, slot1: u32) -> Option<(u8, u8)> {
let slot0 = u8::try_from(slot1.checked_sub(1)?).ok()?;
@@ -110,6 +152,9 @@ pub(crate) fn wire_pos(bag: i64, slot1: u32) -> Option<(u8, u8)> {
5..=10 if slot0 < 36 => {
Some((BANK_BAG_SLOT_FIRST + (bag - BANK_BAG_ID_FIRST) as u8, slot0))
}
KEYRING_CONTAINER if slot0 < KEYRING_SLOTS => {
Some((BAG_PLAYER_INVENTORY, KEYRING_SLOT_FIRST + slot0))
}
EQUIPMENT_BAG if (1..=23).contains(&slot1) || (64..=69).contains(&slot1) => {
Some((BAG_PLAYER_INVENTORY, slot0))
}
@@ -145,6 +190,7 @@ pub(crate) fn slot_guid(store: &ObjectFields, bag: i64, slot0: u8, items: &Items
.filter(|g| *g != 0)
}
BANK_CONTAINER => store.player_bank_slot(slot0).filter(|g| *g != 0),
KEYRING_CONTAINER => store.player_keyring_slot(slot0).filter(|g| *g != 0),
5..=10 => {
let bag_guid = store
.player_bank_bag_slot((bag - BANK_BAG_ID_FIRST) as u8)
@@ -268,8 +314,10 @@ pub(crate) struct ItemSearch {
/// looked at it at all, so an equipped trinket's action button was inert), and **a bag's contents
/// come before the backpack** (the old walk had the backpack first). Bank and buyback are never
/// searched — the walker's default mode expands to `0x47`, which omits the bank's `0x08`, and no
/// bit exists for buyback at all. The **keyring** is the one region benilla does not model (no
/// keyring container ships yet); it is last in the order, so nothing else shifts when it lands.
/// bit exists for buyback at all. The **keyring** (mode bit `0x40`, in that `0x47`) is last in the
/// order and is walked here now that benilla models it (decision 0765) — a key with an on-use
/// spell dropped on the action bar has to find its copy somewhere, and the keyring is the only
/// place the server ever puts one.
pub(crate) fn find_item(
store: &ObjectFields,
items: &Items,
@@ -328,9 +376,79 @@ pub(crate) fn find_item(
return Some((BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + i, guid));
}
}
// The keyring band, last (mode bit 0x40). Walked over the ADDRESSABLE 16, not the level-gated
// count: a slot the level hasn't unlocked can't hold anything, so the extra reads cost nothing
// and the walk needs no level in hand.
for i in 0..KEYRING_SLOTS {
let guid = store.player_keyring_slot(i).unwrap_or(0);
if hit(guid) {
return Some((BAG_PLAYER_INVENTORY, KEYRING_SLOT_FIRST + i, guid));
}
}
None
}
/// The reference's **`HasKey()`** (`0x48ae90`) — "does this player own a key at all?", the one
/// gate that decides whether the keyring exists in the UI (decision 0765). Byte-read: it fetches
/// the active player, then runs the same inventory walker `find_item` transcribes
/// (`0x6223a0` → `0x622420`) with predicate `0x6223d0` — `ItemTemplate.BagFamily == 9`
/// ([`BAG_FAMILY_KEYS`]; `template+0x1d0` is the record's last int32, and `BagFamily` is the last
/// field of `SMSG_ITEM_QUERY_SINGLE_RESPONSE`) — and pushes `1` on a hit, `nil` otherwise.
///
/// **The mode is `0x4f`, not the walker's default `0x47`**: equipment `0x01` | bag slots `0x02` |
/// backpack `0x04` | **bank + bank bags `0x08`** | keyring `0x40`. So a key sitting in the *bank*
/// still shows the keyring — the one region the ordinary item search skips. Buyback has no bit and
/// is never searched, here or anywhere.
///
/// A slot whose item template is still in flight can't be judged and reads as "not a key"; the
/// answer lands within a frame or two and the feed re-pushes.
pub(crate) fn has_key(store: &ObjectFields, items: &mut Items, commands: &NetCommands) -> bool {
// Every guid mode 0x4f reaches, in the walker's own order — a container is recursed into as it
// is passed (the depth-first rule), which is why each bag's contents follow its own slot.
// Collected first, then judged: the template lookup needs `items` mutably (the ask-once query).
fn contents(bag_guid: u64, items: &Items, out: &mut Vec<u64>) {
let Some(f) = items.object(bag_guid) else {
return;
};
let n = f.container_num_slots().unwrap_or(0).min(36) as u8;
out.extend((0..n).map(|j| f.container_slot(j).unwrap_or(0)));
}
let mut guids = Vec::new();
for i in 0..EQUIPMENT_SLOTS {
guids.push(store.player_inv_slot(i).unwrap_or(0));
}
for bag in 0..BAGS {
let bag_guid = store.player_inv_slot(BAG_SLOT_FIRST + bag).unwrap_or(0);
guids.push(bag_guid);
contents(bag_guid, items, &mut guids);
}
for i in 0..PACK_SLOTS {
guids.push(store.player_pack_slot(i).unwrap_or(0));
}
for i in 0..BANK_SLOTS {
guids.push(store.player_bank_slot(i).unwrap_or(0));
}
for bag in 0..BANK_BAGS {
let bag_guid = store.player_bank_bag_slot(bag).unwrap_or(0);
guids.push(bag_guid);
contents(bag_guid, items, &mut guids);
}
for i in 0..KEYRING_SLOTS {
guids.push(store.player_keyring_slot(i).unwrap_or(0));
}
guids.into_iter().any(|guid| {
if guid == 0 {
return false;
}
let Some(entry) = items.object(guid).and_then(|f| f.object_entry()) else {
return false;
};
items
.template(entry, guid, commands)
.is_some_and(|t| t.bag_family == BAG_FAMILY_KEYS)
})
}
/// **The item-use fork** — our `CGItem::Use` (`0x5d8d00`): the one place that decides what "using"
/// an item actually sends. The reference has exactly one such function and every use surface calls
/// it — the bag click (`Script::UseContainerItem` @ `0x4fa430`), the doll click
@@ -368,6 +486,9 @@ pub(crate) fn item_use_command(
// The template BLOCK ordinal the server should cast, not a flag (decision 0666); the
// callers that hold the template name it, the doll drain sends 0.
spell_index,
// A bag/doll click uses the item on yourself; only the lock chain aims one at an
// object (decision 0769 — `target::click`'s key arm).
go_target: None,
},
}
}
@@ -551,10 +672,41 @@ impl Plugin for UiItemsPlugin {
#[cfg(test)]
mod tests {
use super::{find_equip_slot, item_use_command, wire_pos};
use super::{find_equip_slot, item_use_command, keyring_size, wire_pos, KEYRING_CONTAINER};
use crate::net::ClientCommand;
use benilla_ui::script::EQUIPMENT_BAG;
/// The keyring's level ladder, both ends of every rung — the reference's `GetKeyRingSize`
/// (ContainerFrame.lua:773) and vmangos `GetMaxKeyringSize` (Player.h:985) agree on it exactly,
/// which is what lets benilla compute the size instead of being told it. 60 is 1.12's cap, so
/// the 16 rung is unreachable in play — it is here because both authorities carry it.
#[test]
fn keyring_size_walks_the_reference_ladder() {
assert_eq!(keyring_size(1), 4);
assert_eq!(keyring_size(39), 4, "the rung ends at 39");
assert_eq!(keyring_size(40), 8, "40 opens the second rung");
assert_eq!(keyring_size(49), 8);
assert_eq!(keyring_size(50), 12);
assert_eq!(keyring_size(60), 12, "the level cap still sits on 12");
assert_eq!(keyring_size(61), 16, "> 60, unreachable in 1.12");
}
/// The keyring's wire mapping (decision 0765): its Lua slots are player-array slots 81.., so
/// every one lands on the player's own grid — which is what makes keyring↔backpack moves ride
/// the existing `CMSG_SWAP_INV_ITEM` branch with no drain change. Ranged at the wire's 16
/// addressable positions (vmangos `KEYRING_SLOT_END` 97), not the level-gated count.
#[test]
fn wire_pos_maps_the_keyring_onto_the_player_grid() {
assert_eq!(wire_pos(KEYRING_CONTAINER, 1), Some((255, 81)));
assert_eq!(wire_pos(KEYRING_CONTAINER, 16), Some((255, 96)));
assert_eq!(
wire_pos(KEYRING_CONTAINER, 17),
None,
"97 is past KEYRING_SLOT_END — not a position on this wire"
);
assert_eq!(wire_pos(KEYRING_CONTAINER, 0), None);
}
/// The use fork ([`item_use_command`], decision 0664): a non-zero `StartQuest` diverts to
/// `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the ITEM's guid; everything else — including an
/// item whose template never resolved — stays a plain `CMSG_USE_ITEM` on the wire position.
@@ -574,7 +726,9 @@ mod tests {
bag_index: 255,
slot: 23,
// The template BLOCK ordinal rides through untouched (decision 0666).
spell_index: 1
spell_index: 1,
// A bag click never aims at an object (decision 0769).
go_target: None
}
));
// No resolved instance (template still in flight): the fallback is the USE whose refusal
@@ -584,7 +738,8 @@ mod tests {
ClientCommand::UseItem {
bag_index: 19,
slot: 4,
spell_index: 0
spell_index: 0,
go_target: None
}
));
}
@@ -686,18 +841,24 @@ mod find_item_tests {
const SLOT_1: u16 = 50; // CONTAINER_FIELD_SLOT_1 (2 fields per guid)
const INV_SLOT_HEAD: u16 = 486; // PLAYER_FIELD_INV_SLOT_HEAD (2 per guid, 23 slots)
const PACK_SLOT_1: u16 = 532; // PLAYER_FIELD_PACK_SLOT_1 (2 per guid, 16 slots)
const KEYRING_SLOT_1: u16 = 648; // PLAYER_FIELD_KEYRING_SLOT_1 (2 per guid, player slots 81..)
const TRINKET: u32 = 12_930;
const BAG: u32 = 4_500;
/// A player whose slot array points at the given `(player-array index, guid)` pairs.
/// A player whose slot array points at the given `(player-array index, guid)` pairs. Covers the
/// three bands these tests use — equipment/bag buttons 0..22, backpack 23..38, keyring 81.. —
/// each of which is its own descriptor array; the bank/buyback bands in between have no test
/// that needs them.
fn player(slots: &[(u16, u64)]) -> ObjectFields {
let mut pairs = Vec::new();
for &(idx, guid) in slots {
let base = if idx < 23 {
INV_SLOT_HEAD + 2 * idx
} else {
} else if idx < 81 {
PACK_SLOT_1 + 2 * (idx - 23)
} else {
KEYRING_SLOT_1 + 2 * (idx - 81)
};
pairs.push((base, guid as u32));
pairs.push((base + 1, (guid >> 32) as u32));
@@ -810,4 +971,87 @@ mod find_item_tests {
"with it, the spent copy is skipped"
);
}
/// The keyring is the walk's LAST band (mode bit `0x40`) — a key that lives only there is still
/// found, and its wire pair is the player array's own `(255, 81 + n)`. Before decision 0765 the
/// walker stopped at the backpack, so a key on the action bar could never resolve its copy.
#[test]
fn a_key_in_the_keyring_is_found_last() {
const KEY: u32 = 7_146; // The Scarlet Key
let mut items = Items::default();
item(&mut items, 0xE1, KEY, None);
let store = player(&[(81, 0xE1)]);
assert_eq!(find_item(&store, &items, KEY, ALL), Some((255, 81, 0xE1)));
// ...and a copy anywhere earlier still wins: the keyring really is last, not first.
item(&mut items, 0xE2, KEY, None);
let store = player(&[(81, 0xE1), (23, 0xE2)]);
assert_eq!(
find_item(&store, &items, KEY, ALL),
Some((255, 23, 0xE2)),
"the backpack copy precedes the keyring one"
);
}
/// `HasKey()` — the gate the whole keyring UI hangs off (decision 0765), and the one place the
/// **bank** is searched. Byte-read from the reference's `0x48ae90`: predicate `BagFamily == 9`,
/// mode `0x4f` = equipment | bag slots | backpack | BANK | keyring. So: an ordinary item is not
/// a key wherever it sits; a key is a key wherever it sits, the bank included; and buyback —
/// which has no mode bit at all — is never searched.
#[test]
fn has_key_finds_a_key_anywhere_the_reference_looks() {
use super::{has_key, BAG_FAMILY_KEYS};
use crate::items::{test_template, Items};
use crate::net::NetCommands;
const KEY: u32 = 7_146; // The Scarlet Key (bag_family 9, live mangos.item_template)
const BREAD: u32 = 4_540;
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut items = Items::default();
let mut key_tpl = test_template("The Scarlet Key");
key_tpl.bag_family = BAG_FAMILY_KEYS;
items.insert_template(KEY, Some(key_tpl));
items.insert_template(BREAD, Some(test_template("Tough Hunk of Bread")));
item(&mut items, 0xF1, KEY, None);
item(&mut items, 0xF2, BREAD, None);
// Nothing at all.
assert!(!has_key(&player(&[]), &mut items, &commands));
// A non-key in the backpack is not a key.
assert!(!has_key(&player(&[(23, 0xF2)]), &mut items, &commands));
// The director's own case: the key sitting in keyring slot 1.
assert!(has_key(&player(&[(81, 0xF1)]), &mut items, &commands));
// And in the backpack, before it has been filed.
assert!(has_key(&player(&[(23, 0xF1)]), &mut items, &commands));
// The BANK — reachable only because HasKey passes 0x4f rather than the walker's default
// 0x47. `find_item` must NOT see the same copy (its mode omits 0x08).
let mut banked = std::collections::HashMap::new();
banked.insert(39u16, 0xF1u64);
let store = bank_player(&banked);
assert!(
has_key(&store, &mut items, &commands),
"a key in the bank still gives you a keyring"
);
assert_eq!(
find_item(&store, &items, KEY, ALL),
None,
"...while the ordinary item search never reaches the bank"
);
}
/// A player with items in the BANK band (`PLAYER_FIELD_BANK_SLOT_1`), which `player` above
/// deliberately does not cover.
fn bank_player(slots: &std::collections::HashMap<u16, u64>) -> ObjectFields {
const BANK_SLOT_1: u16 = 564; // PLAYER_FIELD_BANK_SLOT_1 (2 per guid, player slots 39..62)
let mut pairs = Vec::new();
for (&idx, &guid) in slots {
let base = BANK_SLOT_1 + 2 * (idx - 39);
pairs.push((base, guid as u32));
pairs.push((base + 1, (guid >> 32) as u32));
}
ObjectFields::from_pairs(&pairs)
}
}
@@ -455,10 +455,12 @@ fn shipped_bag_frame_drives_end_to_end() {
report.errors
);
assert_eq!(
report.frames, 210,
report.frames, 253,
"backpack (window + 16 slots × 2 [button + Cooldown child] + 3 money coins \
+ close = 37) + 4 equipped-bag windows (window + 20 slots × 2 + close = 42 each = 168) \
+ the bag-bar toggle (1) + its 4 bar slots (4) — 0216 slice 2 + the slot Cooldown children"
+ the KEYRING window (another 42 — the same template, decision 0765) \
+ the bag-bar toggle (1) + its 4 bar slots (4) + the keyring button (1) \
— 0216 slice 2 + the slot Cooldown children"
);
// The re-skinned bag purse reuses BenillaMoney_Set/_Clear (they live in MerchantFrame.xml); load
// it too so the shared helper is defined when BenillaBagFrame_Update runs (the same reuse the loot
+290
View File
@@ -1229,3 +1229,293 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// One keyring container fed as the app feeds it: container −2 sized by the level ladder, with a
/// key in the first slot. `size` is the count `keyring_size(level)` would give.
fn keyring(size: u32, occupied: bool) -> ContainerState {
let mut slots = std::collections::HashMap::new();
if occupied {
slots.insert(
1,
ContainerSlot {
// The Scarlet Key — the item the director's own character carries in keyring slot 1.
item_id: 7146,
count: 1,
quality: Some(1),
texture: Some("Interface\\Icons\\INV_Misc_Key_07".into()),
link: Some("|cffffffff|Hitem:7146:0:0:0|h[The Scarlet Key]|h|r".into()),
..Default::default()
},
);
}
ContainerState {
name: Some("Keyring".into()),
num_slots: size,
slots,
}
}
/// How many drawn quads carry a texture path containing `needle`. The engine exposes no
/// `GetTexture`, so "which art is this frame wearing" is asked of the resolved draw list — which is
/// the stronger question anyway (it answers what would actually be on screen).
fn drawn_with(s: &mut UiScript, needle: &str) -> usize {
s.resolve();
s.extract()
.iter()
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)))
.count()
}
/// Load the whole bar+bag surface the keyring spans (its button seats on the action bar and its
/// window is a BagFrame container, so both files are load-bearing).
fn keyring_surface(s: &UiScript) {
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ErrorsFrame.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(s, file);
}
}
/// **The gate** (decision 0765): no key ⇒ no keyring anywhere on the bar — the button is hidden and
/// the bar's two right-hand strips wear the ordinary dwarf plate. The first key flips all of it:
/// the button appears, both strips swap to the keyring plate with the reference's own TexCoords,
/// and the performance meter slides from −227 to −235 to clear the new socket
/// (ref MainMenuBar_UpdateKeyRing, MainMenuBar.lua l.174-183).
///
/// This is the director-reported symptom itself: a character holding a key saw no keyring at all.
#[test]
fn the_first_key_puts_the_keyring_on_the_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
keyring_surface(&s);
s.set_money(0);
// Keyless: the button is hidden and the plate is the dwarf one.
s.set_has_key(false);
s.run("BenillaMainMenuBar_UpdateKeyRing()").unwrap();
assert!(
!s.eval::<bool>("return BenillaKeyRingButton:IsShown()")
.unwrap(),
"no key ⇒ no keyring button"
);
assert_eq!(
drawn_with(&mut s, "UI-MainMenuBar-KeyRing"),
0,
"no key ⇒ every bar strip wears the ordinary dwarf plate"
);
// A key lands. The app pushes HasKey and the wire's BAG_UPDATE reaches the button's OnEvent —
// the exact runtime path, not a direct call.
s.set_has_key(true);
s.set_container(-2, Some(keyring(8, true)));
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
s.eval::<bool>("return BenillaKeyRingButton:IsShown()")
.unwrap(),
"the first key reveals the keyring button"
);
assert_eq!(
drawn_with(&mut s, "UI-MainMenuBar-KeyRing"),
2,
"exactly the two RIGHT-hand strips swap to the keyring plate"
);
for (strip, top, bottom) in [
("BenillaActionBarTexture2", 0.6640625, 1.0),
("BenillaActionBarTexture3", 0.1640625, 0.5),
] {
let (t, b) = s
.eval::<(f64, f64)>(&format!(
"local _, _, top, bottom = {strip}:GetTexCoord() return top, bottom"
))
.unwrap();
assert!(
(t - top).abs() < 1e-9 && (b - bottom).abs() < 1e-9,
"{strip} keeps the reference's own band — got {t}..{b}, want {top}..{bottom}"
);
}
// Geometry cross-check against the reference's own chain. The ref seats its backpack button at
// BOTTOMRIGHT (-6, 2) and steps 37px buttons left with -5 gaps, putting KeyRingButton's left
// edge at -234 from the bar's right; ours steps 36px buttons with -6 gaps and lands at -235.
// Within a pixel of the real bar — which is the check that matters, because the socket the
// button sits in is PAINTED INTO the keyring plate and cannot be nudged to meet it.
let bar_right = s.eval::<f64>("return BenillaActionBar:GetRight()").unwrap();
let button_left = s
.eval::<f64>("return BenillaKeyRingButton:GetLeft()")
.unwrap();
assert!(
(bar_right - button_left - 235.0).abs() < 1.5,
"the keyring button must land in the plate's painted socket — the ref's own -234, got {}",
bar_right - button_left
);
// The performance meter clears the new socket (ref l.180: -227 → -235).
let perf_right = s
.eval::<f64>("return BenillaPerformanceBarFrame:GetRight()")
.unwrap();
let bar_right = s.eval::<f64>("return BenillaActionBar:GetRight()").unwrap();
assert!(
(bar_right - perf_right - 235.0).abs() < 0.01,
"the meter slid to -235 with the keyring up (got {})",
bar_right - perf_right
);
// And it reverts: destroying the last key takes the keyring back off the bar (our divergence
// from the ref's one-way saved-variable latch — see BenillaMainMenuBar_UpdateKeyRing).
s.set_has_key(false);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
!s.eval::<bool>("return BenillaKeyRingButton:IsShown()")
.unwrap(),
"losing the last key takes the button away again"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The window itself: clicking the button opens a container titled "Keyring", stitched from the
/// `-Keyring` plate, showing exactly the level-gated slot count — and it jingles rather than
/// rustling (KeyRingOpen/KeyRingClose, ref ContainerFrame.lua l.116-138).
#[test]
fn the_keyring_button_opens_a_keyring_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
keyring_surface(&s);
s.set_money(0);
s.set_has_key(true);
// A level-44 character: 8 usable slots (the ladder's 40..49 rung).
s.set_container(-2, Some(keyring(8, true)));
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
let _ = s.take_sounds();
assert!(!s
.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap());
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
.unwrap();
assert!(
s.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap(),
"the button opens the keyring"
);
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("KeyRingOpen".into())],
"the keyring has its own open kit, not the backpack's"
);
assert_eq!(
s.eval::<String>("return BenillaKeyRingFrameName:GetText()")
.unwrap(),
"Keyring",
"titled from the KEYRING string, not a bag item's name"
);
assert!(
drawn_with(&mut s, "UI-Bag-Components-Keyring") > 0,
"stitched from the keyring plate, not the ordinary bag sheet"
);
// 8 usable slots: buttons 1..8 shown, the template's remaining 12 hidden — the ordinary
// physIndex-past-size branch, no keyring-specific code.
let shown = s
.eval::<i64>(
"local n = 0 for i = 1, 20 do \
if getglobal('BenillaKeyRingFrameSlot' .. i):IsShown() then n = n + 1 end end return n",
)
.unwrap();
assert_eq!(shown, 8, "only the level-unlocked keyring slots are drawn");
// The key is in the window (slot 1 of 8 is the LAST chain button — size - physIndex + 1).
assert_eq!(
s.eval::<i64>("return C_Container.GetContainerItemID(KEYRING_CONTAINER, 1)")
.unwrap(),
7146
);
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("KeyRingClose".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Dropping a held key on the button files it in the first free slot (ref PutKeyInKeyRing), and a
/// full keyring refuses out loud — where the reference prints nothing at all, having passed a
/// GlobalStrings name that was never defined.
#[test]
fn a_key_dropped_on_the_button_files_itself() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
keyring_surface(&s);
s.set_money(0);
s.set_has_key(true);
// A 4-slot keyring with slot 1 taken, and the key on the cursor (picked out of the backpack).
s.set_container(-2, Some(keyring(4, true)));
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::from([(
3,
ContainerSlot {
item_id: 7146,
count: 1,
..Default::default()
},
)]),
}),
);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
s.run("C_Container.PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
.unwrap();
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 3,
dst_bag: -2,
dst_slot: 2,
count: None,
}],
"filed into the FIRST FREE keyring slot (1 is taken), not the backpack"
);
// Now full: the click refuses with the error line instead of queueing anything.
let mut full = keyring(4, true);
for slot in 2..=4 {
full.slots.insert(
slot,
ContainerSlot {
item_id: 7146,
count: 1,
..Default::default()
},
);
}
s.set_container(-2, Some(full));
s.run("C_Container.PickupContainerItem(0, 3)").unwrap();
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
.unwrap();
assert!(
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine1:GetText()")
.unwrap(),
"Your keyring is full.",
"and says so — the reference's own dangling string name prints nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+76 -19
View File
@@ -29,6 +29,16 @@ fn ui_cost_enabled() -> bool {
*ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1"))
}
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
/// compares all four or none, and a Bevy system has a hard param budget this was eating.
#[derive(Default)]
pub(super) struct GateInputs {
extracted: Vec<benilla_ui::script::ExtractedQuad>,
text_ui: Option<benilla_ui::script::EditBoxTextUi>,
dims: Option<(u32, u32, u32)>,
portraits: std::collections::HashMap<String, crate::portrait::PortraitSource>,
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
/// drain to the log (throttled by being drained — each fires once).
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
@@ -66,10 +76,9 @@ pub(super) fn drive_script(
// caches are monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff
// would then discard. Capture mode never skips (the harness wants exact per-frame output,
// including the cursor-icon quad's live mouse position).
mut prev_extracted: Local<Vec<benilla_ui::script::ExtractedQuad>>,
mut prev_text_ui: Local<Option<benilla_ui::script::EditBoxTextUi>>,
mut prev_dims: Local<Option<(u32, u32, u32)>>,
mut prev_portraits: Local<std::collections::HashMap<String, crate::portrait::PortraitSource>>,
mut prev: Local<GateInputs>,
// This frame's phase split, published for the hover recorder (`hover_log`).
mut ui_cost: ResMut<crate::hover_log::UiFrameCost>,
) {
let Some(mut script) = script else {
return;
@@ -89,7 +98,10 @@ pub(super) fn drive_script(
// Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost
// on an idle frame, and the ledger can only rank what has a name — tick (Lua OnUpdate),
// resolve (layout), measure (the text round-trips), extract (tree walk + rasterize), diff.
let cost_on = ui_cost_enabled();
// The phase marks feed two consumers now: the `[ui-cost]` line and the hover recorder
// (`hover_log`), which writes the same split per frame to a file. Either one arms them.
let printing = ui_cost_enabled();
let cost_on = printing || crate::hover_log::enabled();
let solves_before = cost_on.then(|| script.layout_solves());
let mut t_mark = cost_on.then(std::time::Instant::now);
// Marks phase boundaries under the meter: returns μs since the previous mark and re-arms.
@@ -151,6 +163,17 @@ pub(super) fn drive_script(
// keep the request list empty on quiet frames, so the second resolve is rare.
if let Some(atlas) = font_atlas.as_deref_mut() {
let requests = script.fontstrings_needing_measure();
// The recorder's churn column: WHICH strings a frame had to re-shape. A steady hover that
// keeps asking is the whole question (`hover_log`), and the answer is a string, not a
// count — so the first few come along by name.
if crate::hover_log::enabled() {
ui_cost.measured = requests.len();
ui_cost.measured_texts = requests
.iter()
.take(3)
.map(|r| r.text.chars().take(40).collect::<String>())
.collect();
}
if !requests.is_empty() {
let measures: Vec<(u32, f32, f32, u64)> = requests
.iter()
@@ -277,14 +300,30 @@ pub(super) fn drive_script(
// last frame's values, which the equal inputs prove are this frame's values too.
let dims = (w.to_bits(), h.to_bits(), s.to_bits());
let settled = capture.is_none()
&& *prev_dims == Some(dims)
&& text_ui == *prev_text_ui
&& portraits.0 == *prev_portraits
&& extracted == *prev_extracted;
&& prev.dims == Some(dims)
&& text_ui == prev.text_ui
&& portraits.0 == prev.portraits
&& extracted == prev.extracted;
if settled {
drop(extract_span);
let us_cmp = lap();
if cost_on {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
*ui_cost = crate::hover_log::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
resolve: us_resolve,
measure: us_measure,
extract: us_exm,
convert: us_cmp,
diff: 0,
quads: quads.quads.len(),
solves,
skipped: true,
};
}
if printing {
let solves = script.layout_solves() - solves_before.unwrap_or(0);
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
@@ -295,10 +334,10 @@ pub(super) fn drive_script(
}
return;
}
*prev_dims = Some(dims);
*prev_text_ui = text_ui.clone();
*prev_portraits = portraits.0.clone();
*prev_extracted = extracted.clone();
prev.dims = Some(dims);
prev.text_ui = text_ui.clone();
prev.portraits = portraits.0.clone();
prev.extracted = extracted.clone();
for eq in extracted {
let Some(r) = eq.rect else { continue };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
@@ -558,12 +597,27 @@ pub(super) fn drive_script(
if cost_on {
let us_diff = lap();
let solves = script.layout_solves() - solves_before.unwrap_or(0);
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_exa} diff={us_diff} eq={n_extracted} quads={n_quads} \
solves={solves} changed={} skip=0",
u8::from(changed)
);
*ui_cost = crate::hover_log::UiFrameCost {
measured: ui_cost.measured,
measured_texts: std::mem::take(&mut ui_cost.measured_texts),
tick: us_tick,
resolve: us_resolve,
measure: us_measure,
extract: us_exm,
convert: us_exa,
diff: us_diff,
quads: n_quads,
solves,
skipped: false,
};
if printing {
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_exa} diff={us_diff} eq={n_extracted} quads={n_quads} \
solves={solves} changed={} skip=0",
u8::from(changed)
);
}
}
}
@@ -652,6 +706,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
// The uiScale dial at its identity Default (1.0) — tests pin the byte-identity base;
@@ -733,6 +788,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
@@ -787,6 +843,7 @@ mod extract_gate_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
+4
View File
@@ -137,6 +137,10 @@ pub(crate) struct UiScriptPlugin;
impl Plugin for UiScriptPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(UiScaleCvar(default_ui_scale()))
// The UI pass publishes its per-frame phase split here every frame the cost meter or
// the hover recorder is armed; the producer owns the resource so any minimal app that
// runs `drive_script` (the extract tests) has it.
.init_resource::<crate::hover_log::UiFrameCost>()
.init_resource::<PointerOverUi>()
.init_resource::<PlayerUiHover>()
.init_resource::<UiKeyboardCapture>()
+140 -19
View File
@@ -12,15 +12,15 @@
//! `0x52aa20` law. The standalone-corpse builder joins when corpse objects stream.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::{TooltipTint, UiScript, UnitState};
use crate::go_templates::{GameObjectTemplates, Locks};
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::target::{
go_is_nearest, ring_reaction, Factions, Hovered, HoveredObject, GO_FLAG_LOCKED,
go_is_nearest, ring_reaction, Factions, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_script::UiInput;
@@ -500,11 +500,37 @@ fn lock_type_word(index: u32) -> Option<&'static str> {
})
}
/// The **"Locked" line's colour** (`0x52ab03`-`0x52ab43`, decision 0770).
///
/// The builder seats red `0xc0d3a8` as the default *before* it calls the resolver, then re-colours
/// on the answer. Every non-`Unmet` answer lands on the same green `0xc0d420`, by two separate
/// branches that agree: an opener was found with **no item** and no matched spell (`0x52ab22 je`
/// — the no-requirement case), or an opener was found **with** an item, i.e. a KEY (`0x52ab29
/// jne`). Only a lock nothing can open keeps the red.
///
/// `None` = a flag-locked object with no `Lock.dbc` row at all, which is the reference's
/// no-requirement arm and therefore green — the flag alone is not a refusal.
///
/// **Not modelled:** the third branch, where a *skill* opener satisfied the lock and the colour
/// becomes the difficulty ramp `0x529fa0` (grey `0xc0cf50` / green `0xc0d420` / yellow `0xc0cf18`
/// / orange `0xc0d3a4` / red `0xc0d3a8`, banded at the requirement +0/25/50/100 — the trade-skill
/// ladder). Green is that ramp's comfortable rung, so a well-skilled opener already reads right
/// and only a marginal one reads too green. Pinned in decision 0770; deliberately left for its own
/// change, since it needs the resolver to hand back the margin it currently discards.
fn locked_line_tint(outcome: Option<crate::target::lock::LockOutcome>) -> TooltipTint {
match outcome {
Some(crate::target::lock::LockOutcome::Unmet) => TooltipTint::Red,
_ => TooltipTint::LockOpen,
}
}
#[allow(clippy::too_many_arguments)]
fn drive_mouseover_tooltip(
script: Option<NonSendMut<UiScript>>,
hovered: Res<Hovered>,
hovered_go: Res<HoveredObject>,
// The cursor-arm seat of the GO anchor fork (decision 0766).
window: Query<&Window, With<PrimaryWindow>>,
stores: Query<&ObjectStore>,
// The stored GAMEOBJECT_STATE the lock lines' Action gate reads (decision 0752).
anims: Query<&crate::go_anim::GoAnim>,
@@ -513,9 +539,13 @@ fn drive_mouseover_tooltip(
commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
mut go_templates: ResMut<GameObjectTemplates>,
locks: Option<Res<Locks>>,
mut items: ResMut<Items>,
// The lock chain's own data set, shared verbatim with the click router (`target::lock`) so the
// hover and the click can never disagree about whether a lock is satisfiable — the same reason
// `usable` and the click share one resolver (0752). Carries the go-template, Lock.dbc and
// item caches this system used to take as three separate params.
mut go_inputs: crate::target::lock::GoLockInputs,
// The known-spell set the resolver's SKILL arm scans.
player_actions: Res<crate::ui_action::PlayerActions>,
mut last: Local<LastHover>,
mut last_lines: Local<Option<UnitState>>,
) {
@@ -564,13 +594,40 @@ fn drive_mouseover_tooltip(
return;
}
if let Some((entity, guid)) = go {
// Which arm of the anchor fork this object takes (decision 0766). The reference asks the
// object's own `[obj->vtbl+0x5c]`; what selects it is not pinned, so we key on the one
// distinction the director's two reference observations agree on — a **GENERIC(5)**
// signpost follows the cursor, an interactable GameObject sits in the corner.
//
// Not merely a guess-shaped proxy: after 0762 the only objects that are eligible for a
// tooltip *and* never highlightable are GENERIC ones, so "GENERIC" and "not interactable"
// pick out the same set here. They diverge only for the three always-eligible types
// (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25), which is exactly where a pin would
// settle it. Flagged INTERIM in 0766 rather than presented as verified.
let cursor_seated =
stores.get(entity).map(|s| s.0.gameobject_type_id()) == Ok(GO_TYPE_GENERIC);
let cursor_ui = cursor_seated
.then(|| {
window
.iter()
.next()
.and_then(|w| w.cursor_position().map(|c| (c.x, w.height() - c.y)))
})
.flatten();
if *last == LastHover::Go(guid) {
return; // corner-seated: a pointer moving over the same object re-renders nothing
// The cursor arm follows the pointer; the corner arm has nothing to re-seat.
if let Some((x, y)) = cursor_ui {
script.world_tooltip_move(x, y);
}
return;
}
let Some(template) = go_templates.get(guid).cloned() else {
if cursor_seated && cursor_ui.is_none() {
return; // cursor off-window: nothing to seat the pointer-anchored plate against
}
let Some(template) = go_inputs.templates.get(guid).cloned() else {
// Template in flight: ask once and retry next frame (`last` stays, so the show
// fires the moment the name lands).
go_templates.request(guid, &commands);
go_inputs.templates.request(guid, &commands);
return;
};
// The lock lines, transcribed from the builder `0x52aa20` (decision 0756). Two blocks, in
@@ -597,27 +654,54 @@ fn drive_mouseover_tooltip(
let state = go_store.map_or(benilla_formats::GO_STATE_ACTIVE, |s| {
crate::go_anim::go_state(anims.get(entity).ok(), s)
});
let mut lines: Vec<(String, TooltipTint)> = Vec::new();
if flag_locked {
lines.push(("Locked".to_string(), TooltipTint::Red));
}
if let Some(slot0) = locks
let slots = go_inputs
.locks
.as_ref()
.filter(|_| template.lock_id != 0)
.and_then(|l| l.0.slots(template.lock_id))
.and_then(|l| l.0.slots(template.lock_id));
let mut lines: Vec<(String, TooltipTint)> = Vec::new();
if flag_locked {
// **The "Locked" line is coloured by whether you can actually open it** (director-
// reported: a door you hold the key for read red). The builder sets red `0xc0d3a8` as
// the default (`0x52ab03`), calls the SAME resolver the click uses (`0x52ab14` →
// `0x5f83d0`) and re-colours on its answer (`0x52ab19`-`0x52ab43`):
// · resolver says NO opener -> keep red
// · a KEY item satisfied it -> `0xc0d420` green (spell out-param set AND item
// out-param set: `0x52ab29 jne` takes the green arm)
// · no lock requirement at all-> the same green (`0x52ab22 je`)
// · a SKILL opener satisfied it -> the difficulty ramp `0x529fa0` — NOT modelled;
// see this line's follow-up note in decision 0770. Green is its second rung, so a
// comfortably-skilled opener already reads correctly; a marginal one reads too
// green rather than yellow/orange.
let facts = crate::target::lock::go_facts(go_store.map(|s| (s, state)));
let mut matched = None;
let outcome = slots.map(|slots| {
crate::target::lock::resolve_lock(
slots,
&player_actions.spells,
go_inputs.spells.as_deref(),
self_store,
&go_inputs.items,
facts,
&mut matched,
)
});
lines.push(("Locked".to_string(), locked_line_tint(outcome)));
}
if let Some(slot0) = slots
.map(|s| s[0])
.filter(|s| s.available(state, flag_locked))
{
match slot0.key_type {
benilla_formats::LOCK_KEY_ITEM => {
if let Some(t) = items.template(slot0.index, 0, &commands) {
if let Some(t) = go_inputs.items.template(slot0.index, 0, &commands) {
lines.push((format!("Requires {}", t.name), TooltipTint::White));
}
}
// The opener-*known* arm additionally wants the reference's skill-margin colour
// ramp (`0x529fa0`), which is not pinned — so what we model is the unknown arm,
// which is what a hovering player almost always is. A flagged object stays silent
// there, exactly as the binary does.
// ramp (`0x529fa0`) — now pinned (decision 0770) but not modelled here; what we
// model is the unknown arm, which is what a hovering player almost always is. A
// flagged object stays silent there, exactly as the binary does.
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
if let Some(word) = lock_type_word(slot0.index) {
lines.push((format!("Requires {word}"), TooltipTint::Red));
@@ -626,7 +710,7 @@ fn drive_mouseover_tooltip(
_ => {}
}
}
script.world_tooltip_gameobject(&template.name, &lines);
script.world_tooltip_gameobject(&template.name, &lines, cursor_ui);
*last = LastHover::Go(guid);
return;
}
@@ -858,4 +942,41 @@ mod tests {
"count 1 prints no (N); unowned wraps in the builder's inline red"
);
}
/// The "Locked" line's colour law (decision 0770) — the director's report: a door they held
/// the key for read RED, where the reference reads green.
///
/// The builder's own shape is "red unless the resolver found an opener", and *every* kind of
/// opener lands on the same green — so the mapping is a one-way test on `Unmet`, not a
/// per-arm table. A flag-locked object with no `Lock.dbc` row is the reference's
/// no-requirement arm and is green too: the flag alone never means "you can't".
#[test]
fn the_locked_line_greens_when_the_lock_can_be_opened() {
use crate::target::lock::LockOutcome;
// The report: the Scarlet Key in hand, the Armory Door in front of you.
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenByKey(7146))),
TooltipTint::LockOpen,
"holding the key must read green"
);
// The same door without the key — unchanged, and the control for the fix.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unmet)),
TooltipTint::Red,
"no key still reads red"
);
// A skill opener you know: green. (The reference would ramp this by margin; green is that
// ramp's comfortable rung — see `locked_line_tint`'s note.)
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenBySpell(2575))),
TooltipTint::LockOpen
);
// A lock row that imposes nothing, and a flag-locked object with no row at all.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unlocked)),
TooltipTint::LockOpen
);
assert_eq!(locked_line_tint(None), TooltipTint::LockOpen);
}
}
@@ -233,6 +233,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
portal_refs: portals.refs.clone(),
group_nav,
fogs: root.fogs().to_vec(),
skybox: root.skybox().map(str::to_owned),
group_collision_grids: benilla_assets::collision_tri_grids(&group_collision_tris),
group_collision_tris,
group_camera_only_tris,
@@ -608,6 +608,7 @@ mod tests {
portal_refs: Vec::new(),
group_nav: Vec::new(),
fogs: Vec::new(),
skybox: None,
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
+1
View File
@@ -823,6 +823,7 @@ mod tests {
portal_refs: Vec::new(),
group_nav: Vec::new(),
fogs: Vec::new(),
skybox: None,
group_collision_tris: Vec::new(),
group_camera_only_tris: Vec::new(),
group_collision_bounds: Vec::new(),
+284
View File
@@ -0,0 +1,284 @@
//! The **WMO skybox** — the authored sky a building swaps in for the `Light.dbc` gradient dome while
//! the camera stands in one of its own groups.
//!
//! A WMO root can name a skybox model in its **MOSB** chunk, and each group can ask for it with group
//! flag **`0x40000`**. Both halves matter, and the *gate is the flag*: four 1.12 roots name a skybox
//! that no group ever asks for (DireMaul's instance shell, `Stratholme_A`, and the two Sunken Temple
//! roots — whose MOSB isn't even a model path, it's the string "the temple of atal'hakkar"), so a
//! renderer keyed off the chunk alone would paint skies the reference never shows.
//!
//! `0x40000` is undocumented, so its identity rests on a corpus correlation rather than a citation:
//! across all 815 WMO roots in the 5875 chain the bit **never** appears on a group whose root has no
//! MOSB (`benilla-extract skyboxscan` prints that cross-tab, so the claim is re-checkable against the
//! shipped files rather than trusted from here). Five roots exercise both halves: the four Caverns of
//! Time shells (unreleased in 1.12) and **`Stratholme_B`**, the burning city — which sets the bit on
//! 61 of its 83 groups. Those 61 are the city's open streets, which the WMO authors as INTERIOR
//! groups: standing in King's Square you are, to the client, indoors in a building whose "ceiling" is
//! a painted sky. That is why Stratholme's sky is red and the zone light says otherwise — map 329's
//! only reachable `Light.dbc` atmosphere (the global row 341 → `LightParams` 336) is a khaki-brown
//! gradient with a near-black apex, and it is simply not what the reference draws in there.
//!
//! What we draw is the model as authored: `StratholmeSkybox.m2` is a static, emissive, two-sided cube
//! (three batches × 8 verts, one texture pair per axis), anchored at the camera with identity rotation
//! — the same treatment [`crate::sky`] gives its dome. Occlusion is **not** the box's radius: like
//! every sky element it forces the far depth (`wmo_skybox.wgsl`; the law is in [`crate::sky_order`]),
//! so the world paints over it and the 26-yard shell is free to sit inside the room's own geometry.
//!
//! **Scope.** This swaps the *backdrop* only. The celestial layer above it (sun, moon, stars, cloud
//! dome) and everything the atmosphere drives (fog colour and distance, ambient, diffuse) are
//! untouched — no byte law says the flag reaches them, and the reference shot shows a sun disc over
//! the red sky. If the painted art should also suppress the procedural cloud dome, that is a separate
//! finding and a separate change.
use std::collections::HashSet;
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, MeshVertexBufferLayoutRef, PrimitiveTopology};
use bevy::pbr::{
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
MaterialPlugin,
};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use crate::assets::{LockRecover, WorldAssets};
use crate::player::WorldCamera;
use crate::wmo_portal::CameraInteriorClaim;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::WmoModel;
/// MOGP/MOGI group flag **SHOW_SKYBOX** — this group draws its root's MOSB model as the sky (see the
/// module doc for how the bit was identified). Mirrored between the root's MOGI table and each group
/// file's MOGP header; we read the MOGP copy the loader already keeps in `WmoGroupNav::flags`.
const SHOW_SKYBOX: u32 = 0x40000;
/// Unlit textured backdrop over a `StandardMaterial` shell — the shell supplies `unlit` +
/// `cull_mode: None` (the box is viewed from inside) and the texture; the extension's fragment shader
/// emits the texel raw and forces the sky pass's far depth.
pub type WmoSkyboxMaterial = ExtendedMaterial<StandardMaterial, WmoSkyboxExt>;
/// No per-material uniforms — the fragment reads the base-colour texture and nothing else.
#[derive(Asset, AsBindGroup, Clone, TypePath, Default)]
pub struct WmoSkyboxExt {}
impl MaterialExtension for WmoSkyboxExt {
fn fragment_shader() -> ShaderRef {
"shaders/wmo_skybox.wgsl".into()
}
/// Depth-write OFF, exactly as [`crate::sky::SkyExt`] does it: the backdrop must leave the
/// z-buffer at its clear value over sky pixels so the forced-far glare quads stay occluded by
/// *world* geometry alone.
fn specialize(
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
if let Some(depth) = descriptor.depth_stencil.as_mut() {
depth.depth_write_enabled = false;
}
Ok(())
}
}
/// The skybox model the camera's room asks for this frame — `None` (the overwhelming default) means
/// the [`crate::sky`] gradient dome is the backdrop. Resolved from [`CameraInteriorClaim`]: the same
/// down-ray seed that already names the camera's room for the MFOG fog resolve.
#[derive(Resource, Default, PartialEq, Eq)]
pub(crate) struct CameraWmoSkybox(pub(crate) Option<String>);
/// Marks one batch of a built skybox model, tagged with the model path it belongs to (a session can
/// walk through more than one skybox building, and the built entities are cached, not rebuilt).
#[derive(Component)]
struct WmoSkyboxPart(String);
/// Which skybox paths have been built already — a build is a chain read + BLP decodes, so it happens
/// once per path per session and the entities are then just shown/hidden. A path that FAILED to load
/// is recorded here too: the retry would fail identically every frame, and the gradient dome is the
/// correct thing to fall back to.
#[derive(Resource, Default)]
struct BuiltSkyboxes(HashSet<String>);
/// Ordering handle: [`CameraWmoSkybox`] is settled for the frame after this set. `crate::sky`'s dome
/// gate hangs off it, because the two backdrops must agree *within* a frame — one reading a stale
/// resource is a frame with both drawn or neither.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct WmoSkyResolve;
/// The WMO-skybox subsystem: resolve which model the camera's room wants, build it on first need,
/// then show exactly that one and pin it to the camera.
pub(crate) struct WmoSkyPlugin;
impl Plugin for WmoSkyPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<WmoSkyboxMaterial>::default())
.init_resource::<CameraWmoSkybox>()
.init_resource::<BuiltSkyboxes>()
.add_systems(
Update,
(resolve_camera_skybox, build_skybox, apply_skybox_visibility)
.chain()
// The claim we read is written by the PVS pass in this same schedule. Unordered,
// this reads whichever side of it the executor happened to pick — so a camera
// move that changes rooms lands a frame late, or not at all on the frame it
// matters, and the backdrop flickers between the painted sky and the gradient.
.after(crate::wmo_portal::WmoPvsSet)
.in_set(WmoSkyResolve),
)
// Camera-anchored placement runs post-propagation off the SAME-frame camera pose — the
// slot decision 0504 moved every camera-anchored shell into.
.add_systems(
PostUpdate,
follow_camera.in_set(crate::billboard::BillboardPlace),
);
}
}
/// Which skybox does the camera's room ask for? The claim already names the room (placement + group);
/// the group's `SHOW_SKYBOX` flag and the root's MOSB decide the rest.
fn resolve_camera_skybox(
claim: Res<CameraInteriorClaim>,
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
mut want: ResMut<CameraWmoSkybox>,
) {
let resolved = claim.0.and_then(|c| {
let inst = instances.get(c.room.instance).ok()?;
let model = wmos.get(&inst.handle)?;
let nav = model.group_nav.get(c.room.group as usize)?;
(nav.flags & SHOW_SKYBOX != 0)
.then(|| model.skybox.clone())
.flatten()
});
if want.0 != resolved {
want.0 = resolved;
}
}
/// Build the wanted skybox's entities the first time it is asked for. The model is tiny (three static
/// batches) and there are five in the whole game, so a built one is kept for the session rather than
/// torn down on leaving the room — walking in and out of Stratholme's gate must not re-decode art.
fn build_skybox(
mut commands: Commands,
want: Res<CameraWmoSkybox>,
mut built: ResMut<BuiltSkyboxes>,
world_assets: Option<ResMut<WorldAssets>>,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<WmoSkyboxMaterial>>,
) {
let Some(path) = want.0.as_deref() else {
return;
};
if built.0.contains(path) {
return;
}
let Some(mut world_assets) = world_assets else {
return; // assetless dev run — the gradient dome stays the backdrop
};
let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path);
let subs = match subs {
Ok(subs) if !subs.is_empty() => subs,
Ok(_) => {
warn!("WMO skybox '{path}' has no render batches — keeping the gradient dome");
built.0.insert(path.to_string());
return;
}
Err(e) => {
warn!("WMO skybox '{path}' failed to load, keeping the gradient dome: {e:#}");
built.0.insert(path.to_string());
return;
}
};
for sub in &subs {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
let positions: Vec<[f32; 3]> = sub
.positions
.iter()
.map(|p| wow_to_bevy(*p).to_array())
.collect();
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone());
mesh.insert_indices(Indices::U32(sub.indices.clone()));
// The batch's own authored address mode (decision 0763) — a skybox's UVs sit inside 0..1, but
// reading the flags costs nothing and keeps this lane off a private convention.
let texture = sub
.texture
.as_deref()
.and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images));
let material = materials.add(WmoSkyboxMaterial {
base: StandardMaterial {
base_color_texture: texture,
unlit: true,
cull_mode: None, // authored two-sided, and we view the box from inside
..default()
},
extension: WmoSkyboxExt {},
});
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material),
Transform::default(),
Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one
WmoSkyboxPart(path.to_string()),
));
}
built.0.insert(path.to_string());
}
/// Show the wanted skybox's batches and hide every other built one. This is the sole `Visibility`
/// writer for these entities (the gradient dome's own gate lives in [`crate::sky`], which reads the
/// same resource — one authority per entity class, decision 0025).
fn apply_skybox_visibility(
want: Res<CameraWmoSkybox>,
mut parts: Query<(&WmoSkyboxPart, &mut Visibility)>,
) {
for (part, mut vis) in &mut parts {
let show = want.0.as_deref() == Some(part.0.as_str());
let target = if show {
Visibility::Visible
} else {
Visibility::Hidden
};
if *vis != target {
*vis = target;
}
}
}
/// Pin the box to the camera, world-aligned (identity rotation) so the painted sky stays fixed to the
/// world horizon however the camera turns — the same treatment [`crate::sky::follow_camera`] gives the
/// gradient dome, minus its far-plane scaling.
///
/// The art is drawn at **authored scale** and that is deliberate. Every other shell here scales to a
/// fraction of the far plane, but those radii were only ever standing in for occlusion, and the forced
/// far depth ([`crate::sky_order`], "The depth law") retired that job. What is left for a
/// camera-anchored box to get wrong is the near plane — and Stratholme's is ~26 yd of half-extent
/// against a near plane three orders of magnitude smaller. Scaling it would change nothing on screen
/// (a camera-centred box has no parallax) while quietly making a radius load-bearing again.
#[allow(clippy::type_complexity)]
fn follow_camera(
cam: Query<&GlobalTransform, With<WorldCamera>>,
mut parts: Query<
(&mut Transform, &mut GlobalTransform),
(With<WmoSkyboxPart>, Without<WorldCamera>),
>,
) {
let Some(cam_gt) = cam.iter().next() else {
return;
};
for (mut tf, mut gt) in &mut parts {
tf.translation = cam_gt.translation();
tf.rotation = Quat::IDENTITY;
tf.scale = Vec3::ONE;
// Propagation already ran this frame — the direct global write is what renders.
*gt = GlobalTransform::from(*tf);
}
}