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