locked doors start refusing, and indoor creatures appear already lit
- Locked doors no longer open on a bare right-click. The client now reads a lock the way the original does — the free "quick open" every character carries stops matching keyed doors, so a padlocked door greys the cursor and refuses unless something you have actually opens it. The same gate fixes the skinning knife: it only shows for characters who know Skinning. (Opening a door with its key lands later today.) - A locked door's tooltip matches the original now: one red "Locked" line, a single white requirement line, seated in the screen corner instead of chasing the cursor. - A door that spawns open sends no state field at all on the wire — the server omits zero-valued fields and "open" is zero — and the client read that silence as "unknown" and left the collider solid. Absent now reads as open. This is the first of two independent defects behind open gates that still block; the second falls in the next snapshot. - Indoor creatures and objects fade in already lit by their room, instead of appearing sun-lit and snapping to interior light the instant the fade ends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,8 +48,8 @@ mod stationery;
|
||||
pub use stationery::{load_stationery_catalog, StationeryCatalog, STATIONERY_DEFAULT};
|
||||
mod lock;
|
||||
pub use lock::{
|
||||
load_lock_catalog, LockCatalog, LockSlot, LOCK_KEY_ITEM, LOCK_KEY_NONE, LOCK_KEY_SKILL,
|
||||
MAX_LOCK_SLOTS,
|
||||
load_lock_catalog, LockCatalog, LockSlot, GO_STATE_ACTIVE, GO_STATE_ACTIVE_ALTERNATIVE,
|
||||
GO_STATE_READY, LOCK_KEY_ITEM, LOCK_KEY_NONE, LOCK_KEY_SKILL, MAX_LOCK_SLOTS,
|
||||
};
|
||||
mod lock_type;
|
||||
pub use lock_type::{load_lock_type_catalog, LockTypeCatalog};
|
||||
@@ -109,9 +109,9 @@ pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog};
|
||||
mod spells;
|
||||
pub use spells::{
|
||||
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types,
|
||||
load_spell_durations, load_spell_radii, load_spell_ranges, substitute, ShapeshiftForm,
|
||||
SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay,
|
||||
SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
|
||||
load_spell_durations, load_spell_radii, load_spell_ranges, substitute, OpenLock,
|
||||
ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes,
|
||||
SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
|
||||
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
|
||||
ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM,
|
||||
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL,
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
//!
|
||||
//! Layout verified against build 5875 (mangos `LockEntry`, `DBCStructure.h`, and the RE's
|
||||
//! `[lockRec+0x24]` = `Index[0]` = column 9): **33 fields** — `ID@0`, `Type[8]@1..8`,
|
||||
//! `Index[8]@9..16`, `Skill[8]@17..24`, `Action[8]@25..32` (present in the file, unused here).
|
||||
//! `Index[8]@9..16`, `Skill[8]@17..24`, `Action[8]@25..32`.
|
||||
//!
|
||||
//! **`Action` is a gate, not a label** (decision 0752). Before the client's lock resolver
|
||||
//! (`0x5f83d0`) will even *consider* a slot, it asks `0x5f81d0(gameObject, Action[i])` — a
|
||||
//! predicate over the GameObject's own **state** and its `GO_FLAG_LOCKED` wire bit. See
|
||||
//! [`LockSlot::available`]. Skipping it is why "any locked door opens on right-click": nearly every
|
||||
//! keyed door in 5875 carries a spare `Quick Open` slot with `Action = 0`, and Action 0 means
|
||||
//! *"only when the object is NOT flagged locked"*.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -40,8 +47,66 @@ pub struct LockSlot {
|
||||
pub key_type: u32,
|
||||
/// Key item entry (ITEM) or `LockType` index (SKILL); `0` when empty.
|
||||
pub index: u32,
|
||||
/// Required skill value (SKILL slots); `0` otherwise.
|
||||
/// Required skill value (SKILL slots); `0` otherwise — and `0` does **not** mean "free": the
|
||||
/// client substitutes `GAMEOBJECT_LEVEL × 5` for it (`0x5f84be`).
|
||||
pub skill: u32,
|
||||
/// `Action[i]` — which *operation* this slot performs, and the gate on when it applies.
|
||||
/// See [`LockSlot::available`].
|
||||
pub action: u32,
|
||||
}
|
||||
|
||||
/// `GAMEOBJECT_STATE` (vmangos `GOState`) — the client mirrors it at `go+0x27c` and every gate
|
||||
/// below reads that mirror, not the wire (a chest's lid is opened client-side, so the two differ).
|
||||
pub const GO_STATE_ACTIVE: u32 = 0;
|
||||
pub const GO_STATE_READY: u32 = 1;
|
||||
pub const GO_STATE_ACTIVE_ALTERNATIVE: u32 = 2;
|
||||
|
||||
impl LockSlot {
|
||||
/// Whether this slot applies to a GameObject right now — the client's per-slot gate
|
||||
/// **`0x5f81d0(this = GO, Action[i])`**, byte-transcribed (decision 0752). Both legs of the
|
||||
/// lock resolver `0x5f83d0` call it (`0x5f8450` for a SKILL slot, `0x5f8547` for a KEY slot)
|
||||
/// and **skip the slot** when it answers false, so a gated-out slot can neither satisfy the
|
||||
/// lock nor be opened.
|
||||
///
|
||||
/// `go_state` is the client's stored state (`go+0x27c`, our `GoAnim::state`); `flag_locked` is
|
||||
/// `GAMEOBJECT_FLAGS & GO_FLAG_LOCKED (0x2)`. The `Action` values are operations:
|
||||
///
|
||||
/// | Action | operation | applies when |
|
||||
/// |--------|-----------|--------------|
|
||||
/// | 0 | open | state READY **and** `GO_FLAG_LOCKED` **clear** (`0x5f8212..0x5f8220`) |
|
||||
/// | 1 | unlock | state READY **and** `GO_FLAG_LOCKED` **set** (`0x5f822d..0x5f823a`) |
|
||||
/// | 2 | close | state ACTIVE (`0x5f8247`) |
|
||||
/// | 3 | (state-only) | state READY |
|
||||
/// | 4 | (alt-state) | state ALTERNATIVE (`0x5f81ff`) |
|
||||
/// | other | — | any state but ALTERNATIVE |
|
||||
///
|
||||
/// ALTERNATIVE (2) blocks every action but 4 (`0x5f81e1`).
|
||||
///
|
||||
/// **This is the whole of the "any locked door opens on right-click" report.** The keyed doors
|
||||
/// (Scholomance 1159, Shadowforge 680, Stratholme 879, SM 299, Deadmines 202 …) each carry a
|
||||
/// spare `Quick Open` SKILL slot — `LockType 10`, `Skill 0`, **`Action 0`** — and *every*
|
||||
/// character knows spell 6247 "Opening", which opens LockType 10 with an effect value of 100.
|
||||
/// Without this gate that slot satisfies the lock and the door opens. With it, Action 0 is
|
||||
/// refused the moment `GO_FLAG_LOCKED` is set — which every one of those doors sets. The
|
||||
/// Searing Gorge gate (lock 84) was the reporter's counter-example precisely because it has no
|
||||
/// `Action 0` slot at all: only the key (Action 1) and Pick Lock (Action 1).
|
||||
pub fn available(&self, go_state: u32, flag_locked: bool) -> bool {
|
||||
if self.action == 4 {
|
||||
return go_state == GO_STATE_ACTIVE_ALTERNATIVE;
|
||||
}
|
||||
if go_state == GO_STATE_ACTIVE_ALTERNATIVE {
|
||||
return false;
|
||||
}
|
||||
if matches!(self.action, 0 | 1 | 3) && go_state != GO_STATE_READY {
|
||||
return false;
|
||||
}
|
||||
match self.action {
|
||||
0 => !flag_locked,
|
||||
1 => flag_locked,
|
||||
2 => go_state == GO_STATE_ACTIVE,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `lockId → its 8 requirement slots`, from Lock.dbc.
|
||||
@@ -107,6 +172,7 @@ pub fn load_lock_catalog(chain: &mut Chain) -> Result<LockCatalog> {
|
||||
slot.key_type = u32_at(r, 1 + i).unwrap_or(0);
|
||||
slot.index = u32_at(r, 9 + i).unwrap_or(0);
|
||||
slot.skill = u32_at(r, 17 + i).unwrap_or(0);
|
||||
slot.action = u32_at(r, 25 + i).unwrap_or(0);
|
||||
}
|
||||
locks.insert(id, slots);
|
||||
}
|
||||
@@ -195,5 +261,131 @@ mod tests {
|
||||
chest[1].index, 13,
|
||||
"the keyless-chest LockType spell 6478 opens"
|
||||
);
|
||||
// …and it is an `Action 0` (open) slot, so it applies only to an unflagged object: the
|
||||
// chest opens because chests do NOT carry GO_FLAG_LOCKED, not because Action is ignored.
|
||||
assert_eq!(chest[1].action, 0);
|
||||
assert!(chest[1].available(GO_STATE_READY, false));
|
||||
assert!(!chest[1].available(GO_STATE_READY, true));
|
||||
}
|
||||
|
||||
/// The `Action` column on the real 5875 `Lock.dbc`, on the two rows the "any locked door opens"
|
||||
/// report turns on (decision 0752). A column slip here re-opens the bug silently, so both rows
|
||||
/// are pinned by value. Skips without client data.
|
||||
#[test]
|
||||
fn real_lock_catalog_reads_the_action_column() {
|
||||
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 = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_lock_catalog(&mut chain).expect("load Lock.dbc");
|
||||
|
||||
// Scholomance Door (gameobject_template 174626, door.data1 = lockId 1159; wire flags 34 =
|
||||
// GO_FLAG_LOCKED|NODESPAWN). Five slots: the Skeleton Key, Pick Lock 280, and the three
|
||||
// spares — `Quick Open`/`Quick Close`/`Blasting`.
|
||||
let scholo = cat.slots(1159).expect("lockId 1159 (Scholomance Door)");
|
||||
assert_eq!(
|
||||
(scholo[0].key_type, scholo[0].index, scholo[0].action),
|
||||
(LOCK_KEY_ITEM, 13704, 1),
|
||||
"slot 0 = Skeleton Key, Action 1 (unlock)"
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
scholo[1].key_type,
|
||||
scholo[1].index,
|
||||
scholo[1].skill,
|
||||
scholo[1].action
|
||||
),
|
||||
(LOCK_KEY_SKILL, 1, 280, 1),
|
||||
"slot 1 = Pick Lock 280, Action 1 (unlock)"
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
scholo[2].key_type,
|
||||
scholo[2].index,
|
||||
scholo[2].skill,
|
||||
scholo[2].action
|
||||
),
|
||||
(LOCK_KEY_SKILL, 10, 0, 0),
|
||||
"slot 2 = Quick Open, no skill, Action 0 (open) — THE bug's slot"
|
||||
);
|
||||
// The gate: on a GO_FLAG_LOCKED door, the Quick Open slot does not apply — so the
|
||||
// universally-known "Opening" (6247) never gets to satisfy it. The key and Pick Lock legs
|
||||
// do apply, which is exactly the pair the reference asks the player for.
|
||||
assert!(
|
||||
!scholo[2].available(GO_STATE_READY, true),
|
||||
"Quick Open is gated out by the flag"
|
||||
);
|
||||
assert!(
|
||||
scholo[0].available(GO_STATE_READY, true),
|
||||
"the key still applies"
|
||||
);
|
||||
assert!(
|
||||
scholo[1].available(GO_STATE_READY, true),
|
||||
"Pick Lock still applies"
|
||||
);
|
||||
|
||||
// The Searing Gorge gate (gameobject_template 150137/150138, lockId 84) — the reporter's
|
||||
// counter-example, and the reason the bug looked like "almost all doors". It carries no
|
||||
// Action-0 slot at all, so it refused even before the gate existed.
|
||||
let gorge = cat.slots(84).expect("lockId 84 (Searing Gorge gate)");
|
||||
assert_eq!(
|
||||
(gorge[0].key_type, gorge[0].index, gorge[0].action),
|
||||
(LOCK_KEY_ITEM, 5396, 1),
|
||||
"slot 0 = Key to the Searing Gorge"
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
gorge[1].key_type,
|
||||
gorge[1].index,
|
||||
gorge[1].skill,
|
||||
gorge[1].action
|
||||
),
|
||||
(LOCK_KEY_SKILL, 1, 225, 1),
|
||||
"slot 1 = Pick Lock 225"
|
||||
);
|
||||
assert!(
|
||||
gorge[2..].iter().all(|s| s.key_type == LOCK_KEY_NONE),
|
||||
"no third slot — no Quick Open spare"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`LockSlot::available`] against `0x5f81d0`'s branch table, one row per arm.
|
||||
#[test]
|
||||
fn action_gate_matches_the_reference_branch_table() {
|
||||
let slot = |action| LockSlot {
|
||||
key_type: LOCK_KEY_SKILL,
|
||||
index: 1,
|
||||
skill: 0,
|
||||
action,
|
||||
};
|
||||
// Action 0 (open): READY + not flagged locked.
|
||||
assert!(slot(0).available(GO_STATE_READY, false));
|
||||
assert!(!slot(0).available(GO_STATE_READY, true));
|
||||
assert!(!slot(0).available(GO_STATE_ACTIVE, false));
|
||||
// Action 1 (unlock): READY + flagged locked — the exact mirror.
|
||||
assert!(slot(1).available(GO_STATE_READY, true));
|
||||
assert!(!slot(1).available(GO_STATE_READY, false));
|
||||
assert!(!slot(1).available(GO_STATE_ACTIVE, true));
|
||||
// Action 2 (close): only while ACTIVE, flag irrelevant.
|
||||
assert!(slot(2).available(GO_STATE_ACTIVE, false));
|
||||
assert!(slot(2).available(GO_STATE_ACTIVE, true));
|
||||
assert!(!slot(2).available(GO_STATE_READY, false));
|
||||
// Action 3: READY, flag irrelevant.
|
||||
assert!(slot(3).available(GO_STATE_READY, true));
|
||||
assert!(!slot(3).available(GO_STATE_ACTIVE, false));
|
||||
// Action 4 is the ONLY action an ALTERNATIVE-state object admits, and it admits nothing else.
|
||||
assert!(slot(4).available(GO_STATE_ACTIVE_ALTERNATIVE, false));
|
||||
assert!(!slot(4).available(GO_STATE_READY, false));
|
||||
for action in [0, 1, 2, 3, 5, 19] {
|
||||
assert!(
|
||||
!slot(action).available(GO_STATE_ACTIVE_ALTERNATIVE, false),
|
||||
"action {action} must not apply in the ALTERNATIVE state"
|
||||
);
|
||||
}
|
||||
// An unmodelled action (≥5) falls through to "applies", in any state but ALTERNATIVE.
|
||||
assert!(slot(5).available(GO_STATE_ACTIVE, false));
|
||||
assert!(slot(5).available(GO_STATE_READY, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,23 +247,23 @@ fn real_spell_catalog_reads_open_lock_types() {
|
||||
// The gathering/lockpick openers carry SPELL_EFFECT_OPEN_LOCK; `open_lock_type` is the
|
||||
// LockType they open — the same indices Lock.dbc's skill slots name (mining vein → 3).
|
||||
assert_eq!(
|
||||
cat.get(2575).unwrap().open_lock_type,
|
||||
cat.get(2575).unwrap().open_lock_type(),
|
||||
Some(3),
|
||||
"Mining opens LockType 3"
|
||||
);
|
||||
assert_eq!(
|
||||
cat.get(2366).unwrap().open_lock_type,
|
||||
cat.get(2366).unwrap().open_lock_type(),
|
||||
Some(2),
|
||||
"Herb Gathering opens LockType 2"
|
||||
);
|
||||
assert_eq!(
|
||||
cat.get(1804).unwrap().open_lock_type,
|
||||
cat.get(1804).unwrap().open_lock_type(),
|
||||
Some(1),
|
||||
"Pick Lock opens LockType 1"
|
||||
);
|
||||
// A plain damage spell opens no lock (Effect[0] is not OPEN_LOCK).
|
||||
assert_eq!(
|
||||
cat.get(133).unwrap().open_lock_type,
|
||||
cat.get(133).unwrap().open_lock_type(),
|
||||
None,
|
||||
"Fireball opens no lock"
|
||||
);
|
||||
@@ -290,6 +290,83 @@ fn real_spell_catalog_reads_open_lock_types() {
|
||||
assert_eq!(cat.get(133).unwrap().totems, [0, 0]);
|
||||
}
|
||||
|
||||
/// The two `Effect[0]` values the client latches at spell-learn time (`0x4b25e0` → `[0xb700e4]` /
|
||||
/// `[0xb700e8]`, decision 0752), pinned against the shipped file: the cursor's skin leg refuses to
|
||||
/// show the knife unless one of them is present in the book, so a wrong constant would silently
|
||||
/// re-open the "everyone sees the skinning cursor" report — or, worse, hide it from skinners.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn real_spell_catalog_pins_the_skin_latch_effects() {
|
||||
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 = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
|
||||
|
||||
// Skinning (8613) — `0x4b2623: cmp [esi+0xf4], 0x5f`.
|
||||
assert_eq!(
|
||||
cat.get(8613).unwrap().effect_1,
|
||||
crate::SPELL_EFFECT_SKINNING,
|
||||
"Skinning carries SPELL_EFFECT_SKINNING (95 == 0x5f)"
|
||||
);
|
||||
// Remove Insignia (22027) — the `[0xb700e8]` half, `0x4b2632: cmp [esi+0xf4], 0x74`.
|
||||
assert_eq!(
|
||||
cat.get(22027).unwrap().effect_1,
|
||||
0x74,
|
||||
"Remove Insignia carries SPELL_EFFECT_SKIN_PLAYER_CORPSE (116 == 0x74)"
|
||||
);
|
||||
// Nothing an ordinary caster starts with does — the latch stays empty for a non-skinner.
|
||||
assert_ne!(cat.get(133).unwrap().effect_1, crate::SPELL_EFFECT_SKINNING);
|
||||
assert_ne!(
|
||||
cat.get(6247).unwrap().effect_1,
|
||||
crate::SPELL_EFFECT_SKINNING
|
||||
);
|
||||
}
|
||||
|
||||
/// The **skill an opener provides** on the real Spell.dbc — the left-hand side of the client's lock
|
||||
/// satisfaction test (`0x5f850f`; decision 0752). This walk decides whether a right-click opens a
|
||||
/// lock at all, so its inputs (spellLevel 28 · EffectDieSides 64 · EffectBaseDice 67 ·
|
||||
/// EffectDicePerLevel 70 · EffectRealPointsPerLevel 73 · EffectBasePoints 76) are pinned by
|
||||
/// *result*, against anchors whose right answers are known from the game rather than the file.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn real_spell_catalog_computes_the_lock_skill_an_opener_provides() {
|
||||
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 = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
|
||||
|
||||
// Pick Lock (1804): `4 + 1 + 5.0×(level − 1)` — a rogue's Lockpicking cap, 5×level, exactly.
|
||||
assert_eq!(cat.get(1804).unwrap().open_lock_skill(60), Some(300));
|
||||
assert_eq!(cat.get(1804).unwrap().open_lock_skill(45), Some(225));
|
||||
// Mining (2575) / Herb Gathering (2366): `−1 + 1 + 5.0×level` — the same profession cap, but
|
||||
// quoted at spellLevel 0, so they do not lose the first level the way Pick Lock does.
|
||||
assert_eq!(cat.get(2575).unwrap().open_lock_skill(60), Some(300));
|
||||
assert_eq!(cat.get(2366).unwrap().open_lock_skill(60), Some(300));
|
||||
// Small / Large Seaforium Charge (4056 / 4075): flat `149 + 1` = 150 and `249 + 1` = 250 — and
|
||||
// lock 92 asks for `Blasting 150`. The charge exists to open exactly that door, so the equality
|
||||
// is the cross-check: a column slip anywhere in the walk breaks it.
|
||||
assert_eq!(cat.get(4056).unwrap().open_lock_skill(1), Some(150));
|
||||
assert_eq!(cat.get(4075).unwrap().open_lock_skill(1), Some(250));
|
||||
// The universally-known "Opening"/"Closing" family is flat 100 at every level — which is why
|
||||
// the Action gate, not the value test, is what keeps them off a padlocked door (decision 0752).
|
||||
for id in [3365, 6233, 6246, 6247, 6477, 6478, 21651, 21652] {
|
||||
assert_eq!(
|
||||
cat.get(id).unwrap().open_lock_skill(1),
|
||||
Some(100),
|
||||
"spell {id} is a flat-100 opener"
|
||||
);
|
||||
assert_eq!(cat.get(id).unwrap().open_lock_skill(60), Some(100));
|
||||
}
|
||||
// A spell with no OPEN_LOCK effect provides nothing.
|
||||
assert_eq!(cat.get(133).unwrap().open_lock_skill(60), None);
|
||||
}
|
||||
|
||||
/// The cooldown/cost/range columns on the real build-5875 `Spell.dbc`, pinned 2026-07-10
|
||||
/// against the vmangos `spell_template` rows (MAX(build) ≤ 5875 per entry — the module's
|
||||
/// established cross-check). A slip on any of columns 2/19/20/31/32/36/156/157/158 fails
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A spell's `SPELL_EFFECT_OPEN_LOCK` effect (decision 0752) — which `LockType` it opens, and
|
||||
/// which of the three effect slots carries it (so the value walk reads the right column of the
|
||||
/// per-effect arrays).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenLock {
|
||||
/// `EffectMiscValue` — the `LockType.dbc` index this spell opens (pick-lock 1, herbalism 2,
|
||||
/// mining 3, "Quick Open" 10, "Open Kneeling" 13, blasting 16 …).
|
||||
pub lock_type: u32,
|
||||
/// Which of `Effect[0..3]` is the OPEN_LOCK one.
|
||||
pub effect: usize,
|
||||
}
|
||||
|
||||
/// One spell's display identity.
|
||||
pub struct SpellDisplay {
|
||||
pub name: String,
|
||||
@@ -45,12 +57,14 @@ pub struct SpellDisplay {
|
||||
/// `Effect[0]` (column 61, module docs) — the first effect type. Only
|
||||
/// [`Self::is_melee_auto_attack`] consumes it (`== SPELL_EFFECT_ATTACK`).
|
||||
pub effect_1: u32,
|
||||
/// The `LockType` index this spell can open — its `EffectMiscValue` at whichever of the three
|
||||
/// effects is `SPELL_EFFECT_OPEN_LOCK` — or `None` if it opens no lock. The GameObject
|
||||
/// interact-cast (decision 0239) matches this against a lock slot's `LockType` index across the
|
||||
/// player's known spells: "Opening" for keyless chests, "Mining"/"Herb Gathering"/"Pick Lock"
|
||||
/// for skill locks.
|
||||
pub open_lock_type: Option<u32>,
|
||||
/// This spell's `SPELL_EFFECT_OPEN_LOCK` effect, or `None` if it opens no lock. The GameObject
|
||||
/// interact-cast (decisions 0239/0752) matches it against a lock slot across the player's known
|
||||
/// spells: "Opening" for keyless chests, "Mining"/"Herb Gathering"/"Pick Lock" for skill locks.
|
||||
/// The skill it *provides* is [`Self::open_lock_skill`].
|
||||
pub open_lock: Option<OpenLock>,
|
||||
/// `spellLevel` (column 28, `SpellRec+0x70`) — the level this spell's effect values are quoted
|
||||
/// at; the effect-value walk scales by `casterLevel − spellLevel`, floored at 0 (`0x6e3854`).
|
||||
pub spell_level: u32,
|
||||
/// `Dispel` (column 4, `SpellRec+0x10`) — the `SpellDispelType.dbc` id. The reference client
|
||||
/// reads exactly this field for `GetPlayerBuffDispelType` / `UnitDebuff`'s third return
|
||||
/// (byte-verified, decision 0257). Name it through [`crate::SpellCatalog::dispel_name`] — the
|
||||
@@ -180,9 +194,16 @@ pub struct SpellDisplay {
|
||||
/// misc payload. For an effect-47 opener, slot 0 is the **window routing key** (wow-re
|
||||
/// `tradeskill` node, VERIFIED at `0x6e4bd7`: `!= 0` → the CraftFrame — Enchanting 3, Beast
|
||||
/// Training 1 — else the TradeSkillFrame). The OPEN_LOCK/shapeshift slots are already
|
||||
/// exposed derived ([`Self::open_lock_type`]/[`Self::shapeshift_form`]); this is the raw
|
||||
/// exposed derived ([`Self::open_lock`]/[`Self::shapeshift_form`]); this is the raw
|
||||
/// array.
|
||||
pub effect_misc_value: [i32; 3],
|
||||
/// `EffectDicePerLevel[3]` ([`COL_EFFECT_DICE_PER_LEVEL_1`]) — the integer per-level term of
|
||||
/// the effect-value walk (`0x6e3871`); `0` on every 5875 opener.
|
||||
pub effect_dice_per_level: [i32; 3],
|
||||
/// `EffectRealPointsPerLevel[3]` ([`COL_EFFECT_REAL_POINTS_PER_LEVEL_1`]) — the float
|
||||
/// per-level term (`0x6e3889`). `5.0` on Pick Lock / Mining / Herb Gathering, which is what
|
||||
/// makes their provided lock skill track the 5×level profession cap.
|
||||
pub effect_real_points_per_level: [f32; 3],
|
||||
}
|
||||
|
||||
/// Hand-rolled so `equipped_item_class` defaults to the data's own "no requirement" (`-1`) —
|
||||
@@ -203,7 +224,8 @@ impl Default for SpellDisplay {
|
||||
passive: false,
|
||||
cast_ui: 0,
|
||||
effect_1: 0,
|
||||
open_lock_type: None,
|
||||
open_lock: None,
|
||||
spell_level: 0,
|
||||
dispel: 0,
|
||||
category: 0,
|
||||
recovery_ms: 0,
|
||||
@@ -238,6 +260,8 @@ impl Default for SpellDisplay {
|
||||
effect_base_points: [0; 3],
|
||||
effect_die_sides: [0; 3],
|
||||
effect_base_dice: [0; 3],
|
||||
effect_dice_per_level: [0; 3],
|
||||
effect_real_points_per_level: [0.0; 3],
|
||||
effect_amplitude: [0; 3],
|
||||
effect_apply_aura: [0; 3],
|
||||
effect_radius_index: [0; 3],
|
||||
@@ -256,6 +280,39 @@ impl Default for SpellDisplay {
|
||||
// the catalog that loads that file — [`crate::SpellCatalog::dispel_name`].
|
||||
|
||||
impl SpellDisplay {
|
||||
/// The `LockType` this spell opens, if any — [`Self::open_lock`]'s index alone.
|
||||
pub fn open_lock_type(&self) -> Option<u32> {
|
||||
self.open_lock.map(|o| o.lock_type)
|
||||
}
|
||||
|
||||
/// The **skill value this spell provides** to a lock at `caster_level` — the client's
|
||||
/// effect-value *min* (`0x6e3800`) put through its caller's rounding (`0x6e3760`:
|
||||
/// `round(2x ∓ 0.5) >> 1`), and the left-hand side of the lock resolver's satisfaction test
|
||||
/// (`0x5f850f`: `cmp eax,esi; jge`). `None` when the spell opens no lock.
|
||||
///
|
||||
/// The client compares **this**, never the player's skill block — and that is faithful,
|
||||
/// because the DBC encodes the same number: Pick Lock 1804 at 60 → `4 + 1 + 5.0·(60−1)` =
|
||||
/// **300**, a rogue's Lockpicking cap; Mining 2575 at 60 → `−1 + 1 + 5.0·60` = **300**; and
|
||||
/// Small Seaforium Charge 4056 → `149 + 1` = **150**, exactly the `Blasting 150` its lock
|
||||
/// (id 92) asks for. The universally-known "Opening" spells are flat 100.
|
||||
pub fn open_lock_skill(&self, caster_level: u32) -> Option<i32> {
|
||||
let e = self.open_lock?.effect;
|
||||
// `0x6e3826`: level delta, floored at 0, and only subtracted when spellLevel > 0.
|
||||
let delta = caster_level.saturating_sub(self.spell_level) as f32;
|
||||
let v = self.effect_base_points[e] as f32
|
||||
+ self.effect_base_dice[e] as f32
|
||||
+ self.effect_dice_per_level[e] as f32 * delta
|
||||
+ self.effect_real_points_per_level[e] * delta;
|
||||
// `0x6e3760`'s rounding verbatim: double, bias away from zero by a half, round-to-nearest
|
||||
// (x87 `fistp`), then arithmetic-halve.
|
||||
let doubled = if v >= 0.0 {
|
||||
v * 2.0 - 0.5
|
||||
} else {
|
||||
v * 2.0 + 0.5
|
||||
};
|
||||
Some((doubled.round_ties_even() as i32) >> 1)
|
||||
}
|
||||
|
||||
/// The client's ranged-stance gate, verbatim: `AttributesEx2 & 0x20` (auto-repeat: Auto Shot,
|
||||
/// wand Shoot) **or** `Attributes & 0x2` (uses the ranged slot: every Shoot variant, Throw).
|
||||
/// Byte-verified in wow-re (`SpellRec+0x20&0x20 || +0x18&0x2` — the test every ranged trigger
|
||||
|
||||
@@ -122,7 +122,7 @@ mod tokens;
|
||||
|
||||
pub use cast_times::{load_spell_cast_times, SpellCastTime, SpellCastTimeCatalog};
|
||||
pub use dispel_types::{load_spell_dispel_types, SpellDispelTypes};
|
||||
pub use display::SpellDisplay;
|
||||
pub use display::{OpenLock, SpellDisplay};
|
||||
pub use duration::{load_spell_durations, SpellDuration, SpellDurationCatalog};
|
||||
pub use forms::{load_shapeshift_forms, ShapeshiftForm};
|
||||
pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog};
|
||||
@@ -251,6 +251,10 @@ const SPELL_EFFECT_LEARN_SPELL: u32 = 36;
|
||||
/// interact-cast matches (decision 0239; RE `cursor-system.md` §8, the client's
|
||||
/// `cmp [SpellRec+0xf4], 0x21`). A spell carrying it opens the `LockType` its `EffectMiscValue` names.
|
||||
const SPELL_EFFECT_OPEN_LOCK: u32 = 0x21;
|
||||
/// `spellLevel` — column 28 (`SpellRec+0x70`, the level the effect-value walk subtracts at
|
||||
/// `0x6e3854`). Pinned by value on the extracted 5875 file: Pick Lock 1804 and Fireball rank 1
|
||||
/// read `1`, the professions' openers `0`.
|
||||
const COL_SPELL_LEVEL: usize = 28;
|
||||
/// `EffectApplyAuraName[0]` (column 91, `61 + 10×3` — tenth of the effect-`[3]` blocks; pinned on
|
||||
/// the extracted 5875 file: every form spell — Battle Stance 2457, Bear 5487, Cat 768, Stealth
|
||||
/// 1784, Moonkin 24858 — carries `36` here, and columns 94-96 don't). The stance bar's
|
||||
@@ -295,6 +299,12 @@ const COL_PROC_CHANCE: usize = 25;
|
||||
/// Feign Death); the rest are their natural unsigned/float DBC type.
|
||||
const COL_EFFECT_DIE_SIDES_1: usize = 64;
|
||||
const COL_EFFECT_BASE_DICE_1: usize = 67;
|
||||
/// `EffectDicePerLevel[0]` — column 70 (`SpellRec+0x118`), the integer per-level term of the
|
||||
/// effect-value walk (decision 0752).
|
||||
const COL_EFFECT_DICE_PER_LEVEL_1: usize = 70;
|
||||
/// `EffectRealPointsPerLevel[0]` — column 73 (`SpellRec+0x124`), the **float** per-level term:
|
||||
/// 5.0 on every profession opener, 0.6 on Fireball rank 1 — the two anchors that pin the column.
|
||||
const COL_EFFECT_REAL_POINTS_PER_LEVEL_1: usize = 73;
|
||||
const COL_EFFECT_BASE_POINTS_1: usize = 76;
|
||||
const COL_EFFECT_RADIUS_INDEX_1: usize = 88;
|
||||
const COL_EFFECT_AMPLITUDE_1: usize = 94;
|
||||
@@ -416,7 +426,10 @@ pub const SPELL_EFFECT_TRADE_SKILL: u32 = 47;
|
||||
pub const SPELL_EFFECT_CREATE_ITEM: u32 = 24;
|
||||
/// `SpellEffects` value `95` — `SPELL_EFFECT_SKINNING` (vmangos `SharedDefines.h`,
|
||||
/// `Spell::EffectSkinning`): the corpse-gathering cast the Skin cursor's right-click resolves
|
||||
/// through (0437's gathering finish).
|
||||
/// through (0437's gathering finish). Byte-confirmed as the client's own discriminator: the
|
||||
/// spell-learn path latches a spell with this `Effect[0]` into `[0xb700e4]`
|
||||
/// (`0x4b2623: cmp [esi+0xf4], 0x5f` — `0x5f == 95`), and the cursor's skin leg requires that latch
|
||||
/// (decision 0752).
|
||||
pub const SPELL_EFFECT_SKINNING: u32 = 95;
|
||||
/// `SpellEffects` values `53`/`54` — `SPELL_EFFECT_ENCHANT_ITEM` (permanent) /
|
||||
/// `SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY` (vmangos `SharedDefines.h`): the item-targeted craft
|
||||
@@ -499,6 +512,16 @@ fn spell_schema() -> Schema {
|
||||
s.add_field(SchemaField::new("AuraDescriptionEnUs", FieldType::String));
|
||||
} else if i == COL_SPEED {
|
||||
s.add_field(SchemaField::new("Speed", FieldType::Float32));
|
||||
} else if (COL_EFFECT_REAL_POINTS_PER_LEVEL_1..COL_EFFECT_REAL_POINTS_PER_LEVEL_1 + 3)
|
||||
.contains(&i)
|
||||
{
|
||||
s.add_field(SchemaField::new(
|
||||
format!(
|
||||
"EffectRealPointsPerLevel{}",
|
||||
i - COL_EFFECT_REAL_POINTS_PER_LEVEL_1
|
||||
),
|
||||
FieldType::Float32,
|
||||
));
|
||||
} else if (COL_EFFECT_MULTIPLE_VALUE_1..COL_EFFECT_MULTIPLE_VALUE_1 + 3).contains(&i) {
|
||||
s.add_field(SchemaField::new(
|
||||
format!("EffectMultipleValue{}", i - COL_EFFECT_MULTIPLE_VALUE_1),
|
||||
@@ -562,11 +585,16 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
passive: attributes & ATTR_PASSIVE != 0,
|
||||
cast_ui: u32_at(r, COL_CAST_UI).unwrap_or(0),
|
||||
effect_1: u32_at(r, COL_EFFECT_1).unwrap_or(0),
|
||||
spell_level: u32_at(r, COL_SPELL_LEVEL).unwrap_or(0),
|
||||
// Scan the three effects for OPEN_LOCK and take that effect's LockType (its
|
||||
// EffectMiscValue). Most openers carry it on Effect[0], but the scan is cheap.
|
||||
open_lock_type: (0..3).find_map(|i| {
|
||||
(u32_at(r, COL_EFFECT_1 + i)? == SPELL_EFFECT_OPEN_LOCK)
|
||||
.then(|| u32_at(r, COL_EFFECT_MISC_1 + i).unwrap_or(0))
|
||||
// EffectMiscValue) together with the value inputs the lock resolver compares
|
||||
// against the slot's requirement (decision 0752). Most openers carry it on
|
||||
// Effect[0], but the scan is cheap.
|
||||
open_lock: (0..3).find_map(|i| {
|
||||
(u32_at(r, COL_EFFECT_1 + i)? == SPELL_EFFECT_OPEN_LOCK).then(|| OpenLock {
|
||||
lock_type: u32_at(r, COL_EFFECT_MISC_1 + i).unwrap_or(0),
|
||||
effect: i,
|
||||
})
|
||||
}),
|
||||
dispel: u32_at(r, COL_DISPEL).unwrap_or(0),
|
||||
category: u32_at(r, COL_CATEGORY).unwrap_or(0),
|
||||
@@ -621,6 +649,12 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
effect_base_dice: std::array::from_fn(|i| {
|
||||
i32_at(r, COL_EFFECT_BASE_DICE_1 + i).unwrap_or(0)
|
||||
}),
|
||||
effect_dice_per_level: std::array::from_fn(|i| {
|
||||
i32_at(r, COL_EFFECT_DICE_PER_LEVEL_1 + i).unwrap_or(0)
|
||||
}),
|
||||
effect_real_points_per_level: std::array::from_fn(|i| {
|
||||
f32_at(r, COL_EFFECT_REAL_POINTS_PER_LEVEL_1 + i).unwrap_or(0.0)
|
||||
}),
|
||||
effect_amplitude: std::array::from_fn(|i| {
|
||||
u32_at(r, COL_EFFECT_AMPLITUDE_1 + i).unwrap_or(0)
|
||||
}),
|
||||
|
||||
@@ -260,6 +260,9 @@ const FIELD_PLAYER_BANK_BAG_SLOT_1: u16 = 612; // 564 + 24×2; 6 bag slots × 2
|
||||
// anchors (ui/scratch/buyback-data-path.md: playerBlock +0x1038/+0x1068) rebased through the
|
||||
// tested COINAGE anchor: block base = 1176 − 0xf70/4 = 188 → 188+1038 = 1226, 188+1050 = 1238).
|
||||
const FIELD_PLAYER_VENDORBUYBACK_SLOT_1: u16 = 624; // 12 slots × 2 (item guids)
|
||||
// PLAYER_FIELD_KEYRING_SLOT_1 — the next link of the same chain the buyback comment walks
|
||||
// (624 + 12×2 = 648; +32×2 = 712 = FARSIGHT ✓, which the chain's far end already pins).
|
||||
const FIELD_PLAYER_KEYRING_SLOT_1: u16 = 648; // 32 slots × 2 (item guids), wire slots 81–112
|
||||
const FIELD_PLAYER_BUYBACK_PRICE_1: u16 = 1226; // 12 × u32 copper, indexed slot−69
|
||||
const FIELD_PLAYER_BUYBACK_TIMESTAMP_1: u16 = 1238; // 12 × u32 — the client's sort key only
|
||||
// PLAYER_FIELD_COINAGE, our purse in copper (INT, PRIVATE — sent only for our own player, the exact
|
||||
|
||||
@@ -92,6 +92,14 @@ 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
|
||||
/// (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)))?
|
||||
}
|
||||
/// `PLAYER_FIELD_VENDORBUYBACK_SLOT_1 + 2i` — the item guid parked in buyback slot `i` (0–11;
|
||||
/// the wire's absolute inventory slot is `69 + i`). `Some(0)` = empty.
|
||||
pub fn player_buyback_slot(&self, i: u8) -> Option<u64> {
|
||||
|
||||
@@ -90,6 +90,7 @@ mod tooltip;
|
||||
mod tooltip_item;
|
||||
mod tooltip_spell;
|
||||
mod tooltip_unit;
|
||||
pub use tooltip_unit::TooltipTint;
|
||||
mod trade;
|
||||
mod tradeskill;
|
||||
mod trainer;
|
||||
|
||||
@@ -42,6 +42,17 @@ const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.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];
|
||||
|
||||
/// A GameObject tooltip line's colour, as the builder `0x52aa20` picks it (decision 0756). The
|
||||
/// app half decides *which* line gets which tint from the lock law; this enum is only the
|
||||
/// crate-boundary spelling, so the colour constants stay private to the UI engine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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`).
|
||||
Red,
|
||||
}
|
||||
|
||||
/// The rank word table — byte-verified `0x854158[]`: rare-elite prints ELITE, rare prints
|
||||
/// nothing (there is no distinct "Rare Elite" word in 1.12's builder).
|
||||
fn rank_word(rank: u32) -> Option<&'static str> {
|
||||
@@ -319,46 +330,37 @@ impl super::UiScript {
|
||||
}
|
||||
|
||||
/// Show the world-mouseover GAMEOBJECT tooltip — the byte-verified GO builder `0x52aa20`
|
||||
/// (decision 0276): the NAME (gold) + the red lock-requirement lines ("Requires <key>",
|
||||
/// the Lock.dbc chain) when the object is flag-locked. Seated centred ABOVE the cursor and
|
||||
/// clamped to the screen, like the minimap blip tooltip — the reference's signpost hover
|
||||
/// shows the engine plate AT the pointer, not the FrameXML corner (director's reference
|
||||
/// shot, 2026-07-13, superseding 0281's "units + GOs → corner" row for the GO half; the
|
||||
/// exact engine anchor law is INTERIM pending its pin). Follows the pointer via
|
||||
/// [`Self::world_tooltip_move`]. Same fade lifecycle as the unit flow (no
|
||||
/// `UPDATE_MOUSEOVER_UNIT` — that recolor is the unit line's).
|
||||
/// (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.
|
||||
///
|
||||
/// Same fade lifecycle as the unit flow (no `UPDATE_MOUSEOVER_UNIT` — that recolor is the
|
||||
/// unit line's).
|
||||
pub fn world_tooltip_gameobject(
|
||||
&mut self,
|
||||
name: &str,
|
||||
requirements: &[String],
|
||||
ui_x: f32,
|
||||
ui_y: f32,
|
||||
lines: &[(String, TooltipTint)],
|
||||
) -> bool {
|
||||
let (h, id, root_id) = {
|
||||
let (h, id) = {
|
||||
let mut model = self.model_mut();
|
||||
let Some(h) = model.arena.lookup("GameTooltip") else {
|
||||
return false;
|
||||
};
|
||||
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)
|
||||
let id = model.frame_id(h);
|
||||
(h, 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![])
|
||||
{
|
||||
let mut model = self.model_mut();
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// Anchor only — the screen clamp is the tooltip FRAME's own flag
|
||||
// (`Frame::clamped_to_screen`), synced with live extents at every resolve.
|
||||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||||
// Compare-then-touch: a still cursor re-seats the plate at the same point every
|
||||
// frame — tier 1 of the layout gate stays quiet unless the pointer actually moved.
|
||||
let same =
|
||||
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
if !same {
|
||||
input.anchors = vec![new];
|
||||
model.touch_layout();
|
||||
}
|
||||
self.push_error(e);
|
||||
}
|
||||
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
|
||||
Ok(w) => w,
|
||||
@@ -374,8 +376,12 @@ impl super::UiScript {
|
||||
}
|
||||
fire_cleared(&self.lua, h);
|
||||
append_line(&self.lua, &wrapper, (name.to_string(), GOLD), None, false)?;
|
||||
for req in requirements {
|
||||
append_line(&self.lua, &wrapper, (req.clone(), RED), None, false)?;
|
||||
for (text, tint) in lines {
|
||||
let colour = match tint {
|
||||
TooltipTint::White => WHITE,
|
||||
TooltipTint::Red => RED,
|
||||
};
|
||||
append_line(&self.lua, &wrapper, (text.clone(), colour), None, false)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
@@ -872,11 +872,7 @@ pub(super) fn attach_entity_visuals(
|
||||
if let (Some(blend), false) = (fade_blend, reattached) {
|
||||
unit_will_fade = true;
|
||||
child.insert((
|
||||
PendingAppearFade {
|
||||
cutout: mat.clone(),
|
||||
blend: blend.clone(),
|
||||
since: now,
|
||||
},
|
||||
PendingAppearFade { since: now },
|
||||
FadeMaterials {
|
||||
cutout: mat.clone(),
|
||||
blend: blend.clone(),
|
||||
|
||||
@@ -908,11 +908,7 @@ pub(super) fn attach_held_items(
|
||||
match effective {
|
||||
PartFade::Steady => {}
|
||||
PartFade::Pending(since) => {
|
||||
child.insert(PendingAppearFade {
|
||||
cutout: part.material.clone(),
|
||||
blend: part.fade_blend.clone().unwrap(),
|
||||
since,
|
||||
});
|
||||
child.insert(PendingAppearFade { since });
|
||||
}
|
||||
PartFade::Live(started) => {
|
||||
child.insert(RenderFade {
|
||||
@@ -920,8 +916,6 @@ pub(super) fn attach_held_items(
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
cutout: part.material.clone(),
|
||||
blend: part.fade_blend.clone().unwrap(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,17 @@ pub(crate) struct GoAnim {
|
||||
last_wire: Option<u32>,
|
||||
}
|
||||
|
||||
/// The GameObject's **stored** state — the binary's `go+0x27c`, which is what every consumer reads
|
||||
/// (the §243 animation, `usable`'s state pre-gate, and the lock chain's per-slot Action gate,
|
||||
/// decision 0752). [`GoAnim::state`] when the object is on the animation machine (it carries the
|
||||
/// client-side predictions the wire never sends — a chest's lid), else the wire field, else the
|
||||
/// wire default `0` = ACTIVE for a field vmangos omitted because it was zero.
|
||||
pub(crate) fn go_state(anim: Option<&GoAnim>, store: &ObjectStore) -> u32 {
|
||||
anim.and_then(|a| a.state)
|
||||
.or_else(|| store.0.gameobject_state())
|
||||
.unwrap_or(GO_STATE_ACTIVE)
|
||||
}
|
||||
|
||||
/// Which GameObject types get the state-driven **animated** instance (skinned lid/door + §243
|
||||
/// sequences) — **the byte-verified type census**, not a guess (wow-re `gameobject-anim-arm.md` §2f).
|
||||
///
|
||||
@@ -133,6 +144,21 @@ fn collision_follows_state(type_id: i32) -> bool {
|
||||
matches!(type_id, 0 | 1)
|
||||
}
|
||||
|
||||
/// Whether a door/button's collider is solid, from its **wire** `GAMEOBJECT_STATE` (decision 0757).
|
||||
///
|
||||
/// The load-bearing case is `None`. An absent field is not "unknown" — it is the wire default `0`
|
||||
/// = `GO_STATE_ACTIVE` = **open**, because vmangos omits zero-valued fields from the create block
|
||||
/// (the same law `go_templates` already documents for an absent `GAMEOBJECT_TYPE_ID` meaning
|
||||
/// DOOR). Treating it as unknown and bailing left the collider enabled on **every door that spawns
|
||||
/// open**, which is the whole of "GameObjects that block passage while visually open": Zul'Gurub's
|
||||
/// Forcefield (180497) and Stratholme's `Doodad_SmallPortcullis03/04/08/09` are all `type 0`,
|
||||
/// `startOpen = 1`, spawned at `state = 0`. It also explains the GM workaround the reporter found
|
||||
/// — toggling the object twice un-sticks it — because the first toggle sends a *non-zero* state,
|
||||
/// so the field finally exists on the wire, and the trip back to `0` then reads as a real value.
|
||||
fn collider_is_solid(wire_state: Option<u32>) -> bool {
|
||||
wire_state.unwrap_or(GO_STATE_ACTIVE) == GO_STATE_READY
|
||||
}
|
||||
|
||||
/// A cast launched at a GameObject (`SMSG_SPELL_GO` carrying a `TARGET_FLAG_GAMEOBJECT`), bridged from the
|
||||
/// net apply layer to this module (decision 0250). [`open_go_lid`] opens the target's lid/door iff the
|
||||
/// spell carries an open-lock effect and the GO is an animated type — the client's `Spell_C` open path.
|
||||
@@ -265,7 +291,12 @@ fn sync_wire_go_state(
|
||||
mut gos: Query<(&ObjectStore, &mut GoAnim), Or<(Changed<ObjectStore>, Added<GoAnim>)>>,
|
||||
) {
|
||||
for (store, mut anim) in &mut gos {
|
||||
let wire = store.0.gameobject_state();
|
||||
// Absent ⇒ the wire default `0` = ACTIVE (decision 0757) — vmangos omits zero fields, so a
|
||||
// door that spawns OPEN sends none. Reading that as "unknown" left `state` at `None`, and
|
||||
// the object rested at its loader pose instead of Opened. The `last_wire` guard is
|
||||
// unaffected: a constant wire state (a chest's `Some(1)`) still compares equal frame to
|
||||
// frame, so the client-predicted lid is still never re-closed by an unrelated delta.
|
||||
let wire = Some(store.0.gameobject_state().unwrap_or(GO_STATE_ACTIVE));
|
||||
if wire == anim.last_wire {
|
||||
continue; // an unrelated field changed (position, flags, dyn-flags) — not our transition
|
||||
}
|
||||
@@ -291,7 +322,7 @@ fn open_go_lid(
|
||||
let is_open_lock = spells
|
||||
.as_deref()
|
||||
.and_then(|s| s.catalog.get(spell_id))
|
||||
.is_some_and(|d| d.open_lock_type.is_some());
|
||||
.is_some_and(|d| d.open_lock.is_some());
|
||||
if !is_open_lock {
|
||||
continue;
|
||||
}
|
||||
@@ -408,10 +439,7 @@ fn drive_go_collision(
|
||||
if !collision_follows_state(store.0.gameobject_type_id()) {
|
||||
continue;
|
||||
}
|
||||
let Some(state) = store.0.gameobject_state() else {
|
||||
continue;
|
||||
};
|
||||
let solid = state == GO_STATE_READY;
|
||||
let solid = collider_is_solid(store.0.gameobject_state());
|
||||
if solid && disabled {
|
||||
commands.entity(entity).remove::<ColliderDisabled>();
|
||||
} else if !solid && !disabled {
|
||||
@@ -439,6 +467,18 @@ pub(crate) fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The wire default that let open doors block (decision 0757). `None` must read ACTIVE/open,
|
||||
/// never "unknown" — vmangos omits the zero-valued field, so `None` IS the open state for
|
||||
/// every door that spawns open (ZG's Forcefield, Stratholme's small portcullises).
|
||||
#[test]
|
||||
fn an_absent_wire_state_is_open_not_unknown() {
|
||||
assert!(!collider_is_solid(None), "absent == ACTIVE(0) == passable");
|
||||
assert!(!collider_is_solid(Some(GO_STATE_ACTIVE)));
|
||||
assert!(collider_is_solid(Some(GO_STATE_READY)));
|
||||
// ALTERNATIVE (destroyed) is passable too — only READY is solid.
|
||||
assert!(!collider_is_solid(Some(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_poses_match_the_verified_table() {
|
||||
assert_eq!(rest_anim(1), Some(0x93)); // READY → Closed
|
||||
|
||||
+241
-50
@@ -186,6 +186,21 @@ impl InteriorLit {
|
||||
matches!(self.applied, Some(AppliedLaw::Bake(_)))
|
||||
}
|
||||
|
||||
/// The steady (non-feathering) material for the part's CURRENT law — the classifier's own
|
||||
/// choice, exposed so the fade writers settle a part onto exactly what the classifier would
|
||||
/// have written rather than onto a `cutout` latched before the law was known (decision 0755).
|
||||
///
|
||||
/// The exterior and day/night (Matte) states share the exterior material: since 0354 the
|
||||
/// difference between them is the node's intensity target (the tag byte `entity_shade` ramps),
|
||||
/// not a separate material. Only the footprint bake swaps the variant — and a matte-KIND part
|
||||
/// under a bake-law anchor has no bake variant to swap to, so it keeps the exterior one.
|
||||
pub(crate) fn steady_material(&self) -> &Handle<WowModelMaterial> {
|
||||
match (self.applied, &self.kind) {
|
||||
(Some(AppliedLaw::Bake(_)), InteriorKind::Bake { material, .. }) => material,
|
||||
_ => &self.exterior,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new(kind: InteriorKind, exterior: Handle<WowModelMaterial>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
@@ -193,6 +208,22 @@ impl InteriorLit {
|
||||
applied: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: a part already recorded as riding the Bake law, so
|
||||
/// [`crate::model_fade::FadeMaterials::material_for`] can be exercised over every law without
|
||||
/// driving a full classifier run from another module ([`AppliedLaw`] is this module's business
|
||||
/// and stays private).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn applied_bake_for_test(
|
||||
kind: InteriorKind,
|
||||
exterior: Handle<WowModelMaterial>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
exterior,
|
||||
applied: Some(AppliedLaw::Bake(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `on_add` hook: a freshly spawned part asks for its anchor's standing law (drained by
|
||||
@@ -204,11 +235,14 @@ fn enqueue_new_part(mut world: DeferredWorld, ctx: HookContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade latch: the appear-fade owned this part's material/tag while it lived (the classifier's
|
||||
/// write query excludes fading parts entirely), so the moment `RenderFade` leaves, the part
|
||||
/// re-authors from its anchor's law — the event-driven replacement for the old settled-path
|
||||
/// stale-slot repair (the "unit stays black indoors" bug: a slot freed while its part sat
|
||||
/// excluded). Removal on despawn also lands here; the drain drops dead entries.
|
||||
/// Fade latch: re-author the part from its anchor's law the moment `RenderFade` leaves.
|
||||
///
|
||||
/// Since 0755 this is a **backstop**, not the convergence path it once was — the classifier now
|
||||
/// tracks a part's law right through its ramp, and `apply_render_fade` settles the material onto
|
||||
/// that law itself at the latch, so the forced re-author is normally a no-op. It stays because its
|
||||
/// failure mode is the "unit stays black indoors" bug (a probe slot freed while its part sat
|
||||
/// unauthored), which has cost us twice, and one forced write per part at the end of a ramp is
|
||||
/// nothing. Removal on despawn also lands here; the drain drops dead entries.
|
||||
fn enqueue_on_fade_latch(
|
||||
fade_end: On<Remove, RenderFade>,
|
||||
parts: Query<(), With<InteriorLit>>,
|
||||
@@ -260,6 +294,7 @@ pub(crate) struct BakeState {
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(crate) fn classify_entity_interior(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
residency: Res<WmoResidency>,
|
||||
wmos: Res<Assets<WmoModel>>,
|
||||
instances: Query<&WmoPortalInstance>,
|
||||
@@ -278,22 +313,27 @@ pub(crate) fn classify_entity_interior(
|
||||
seats: Query<&PropProbeSlot>,
|
||||
mut queue: ResMut<InteriorReauthor>,
|
||||
part_anchors: Query<&ClassifiedBy>,
|
||||
// Skip an entity whose appear-fade is **pending or live**: it must stay invisible (on its blend twin
|
||||
// at α≈0) until armed, and while ramping `apply_render_fade` owns its material + `MeshTag` (the fade
|
||||
// alpha). We reclaim the channel (steady material) only once both are gone — i.e. the fade has
|
||||
// latched (the latch observer re-enqueues). Without this the classifier would fight the fade for
|
||||
// the tag (and force the pending entity opaque).
|
||||
mut parts: Query<
|
||||
(
|
||||
&mut InteriorLit,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut MeshTag,
|
||||
),
|
||||
(Without<RenderFade>, Without<PendingAppearFade>),
|
||||
>,
|
||||
// Fading parts are **included** (decision 0755). The light law and the fade alpha are
|
||||
// orthogonal, and they are deconflicted by field, not by lockout: the classifier's payload
|
||||
// writes carry the tag's alpha field through (`mesh_tag::with_interior_probe` /
|
||||
// `with_exterior_reset`), and a fading part takes the BLEND twin of its law from the very
|
||||
// rule the ramp itself applies (`FadeMaterials::material_for`) — same handle, either order,
|
||||
// no fight. Excluding them is what left a streamed indoor entity with no law at all for the
|
||||
// whole 2 s ramp, so it appeared under exterior light and swapped to its room's in one frame
|
||||
// at the latch.
|
||||
mut parts: Query<(
|
||||
&mut InteriorLit,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut MeshTag,
|
||||
Option<&crate::model_fade::FadeMaterials>,
|
||||
Has<RenderFade>,
|
||||
Has<PendingAppearFade>,
|
||||
)>,
|
||||
) {
|
||||
let _t0 = std::time::Instant::now();
|
||||
let (mut n_anchors, mut n_resolved, mut n_written) = (0usize, 0usize, 0usize);
|
||||
// Anchors that wanted a resolve but had every part fade-excluded — the appear-fade lockout.
|
||||
let mut n_fade_blocked = 0usize;
|
||||
let mut resolve_us = 0.0f32;
|
||||
for (anchor, anchor_t, lit_parts, mut state) in &mut anchors {
|
||||
n_anchors += 1;
|
||||
@@ -324,13 +364,15 @@ pub(crate) fn classify_entity_interior(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Re-resolving: the kind comes from the anchor's first classifiable part. Every part
|
||||
// excluded (a whole-model appear-fade, pending or live) ⇒ no resolve at all — the fade
|
||||
// owns the channel, and the latch observer brings the parts back through the queue.
|
||||
// Re-resolving: the kind comes from the anchor's first classifiable part. Since 0755 a
|
||||
// fading part is classifiable, so this only bails on an anchor whose parts have all been
|
||||
// despawned (a teardown mid-frame) — `fade_blocked` is kept as the tripwire that the
|
||||
// lockout has not crept back in.
|
||||
let Some(kind) = lit_parts
|
||||
.iter()
|
||||
.find_map(|part| parts.get(part).ok().map(|(lit, _, _)| lit.kind.clone()))
|
||||
.find_map(|part| parts.get(part).ok().map(|(lit, ..)| lit.kind.clone()))
|
||||
else {
|
||||
n_fade_blocked += 1;
|
||||
continue;
|
||||
};
|
||||
let seated = seats.get(anchor).ok().map(|s| s.0);
|
||||
@@ -384,7 +426,8 @@ pub(crate) fn classify_entity_interior(
|
||||
&& std::env::var_os("WOW_INTERIOR_LOG").is_some()
|
||||
{
|
||||
eprintln!(
|
||||
"[interior] anchor {anchor:?} at ({:.1}, {:.1}, {:.1}) -> {}",
|
||||
"[interior] t {:.2} anchor {anchor:?} at ({:.1}, {:.1}, {:.1}) -> {}",
|
||||
time.elapsed_secs(),
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
@@ -396,13 +439,16 @@ pub(crate) fn classify_entity_interior(
|
||||
);
|
||||
}
|
||||
for part in lit_parts.iter() {
|
||||
if let Ok((mut lit, mut material, mut tag)) = parts.get_mut(part) {
|
||||
if let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part)
|
||||
{
|
||||
let fade = fm.filter(|_| ramping || pending);
|
||||
n_written += usize::from(write_part_law(
|
||||
law,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
false,
|
||||
fade,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -420,9 +466,10 @@ pub(crate) fn classify_entity_interior(
|
||||
let Some(state) = state.as_deref_mut() else {
|
||||
continue; // law not resolved yet — the anchor's first resolve writes every part
|
||||
};
|
||||
let Ok((mut lit, mut material, mut tag)) = parts.get_mut(part) else {
|
||||
continue; // still fade-excluded — the latch re-enqueues
|
||||
let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part) else {
|
||||
continue; // despawned between the enqueue and the drain
|
||||
};
|
||||
let fade = fm.filter(|_| ramping || pending);
|
||||
// The mixed-kind hole: a bake-capable part joining an anchor whose law was resolved from
|
||||
// a matte-kind part must force a re-resolve (drop the record; next frame re-rays) — the
|
||||
// standing law can't say Bake. Written with the standing law this frame regardless, so
|
||||
@@ -436,6 +483,7 @@ pub(crate) fn classify_entity_interior(
|
||||
&mut material,
|
||||
&mut tag,
|
||||
true,
|
||||
fade,
|
||||
));
|
||||
}
|
||||
// `WOW_INTERIOR_COST=1`: this lane's per-frame cost, in the terms that diagnose it — how many
|
||||
@@ -446,7 +494,7 @@ pub(crate) fn classify_entity_interior(
|
||||
static COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
if *COST.get_or_init(|| std::env::var_os("WOW_INTERIOR_COST").is_some()) {
|
||||
eprintln!(
|
||||
"[interior-cost] anchors={n_anchors} resolved={n_resolved} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}",
|
||||
"[interior-cost] anchors={n_anchors} resolved={n_resolved} fade_blocked={n_fade_blocked} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}",
|
||||
resolve_us / 1000.0,
|
||||
_t0.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
@@ -454,38 +502,49 @@ pub(crate) fn classify_entity_interior(
|
||||
}
|
||||
|
||||
/// Write one part's material + tag for `law` — the single place a part's channel is authored.
|
||||
/// Change-gated on the part's last-written record unless `force` (a transient author — fade,
|
||||
/// zoom feather — overwrote the channel while `applied` stayed current). Returns whether it wrote.
|
||||
/// Change-gated on the part's last-written record unless `force` (a transient author — the zoom
|
||||
/// feather — overwrote the channel while `applied` stayed current). Returns whether it wrote.
|
||||
///
|
||||
/// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field plus an
|
||||
/// opaque alpha field (a fade feather composes through `with_alpha` without clobbering the
|
||||
/// slot); the other laws reset to the opaque exterior payload (shade byte 0 — `entity_shade`
|
||||
/// runs after the classifier and re-asserts the ramped intensity byte the same frame; it skips
|
||||
/// only Bake parts). BOTH indoor laws carry the INTERIOR_FOG_BIT (Bake bakes it in): the
|
||||
/// reference fogs a unit by the unit's OWN interior classification, so an indoor day/night
|
||||
/// character keeps the room's fog — never the storm's near veil — while the exterior law returns
|
||||
/// it to the scene fog (wow-re `m2-unit-interior-fog.md`; the director's corridor-vs-porch
|
||||
/// walk-out). Every arm carries the part's rig field through (decision 0720): a skinned part
|
||||
/// keeps its palette across the indoor/outdoor transition.
|
||||
/// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field; the other laws
|
||||
/// reset to the plain exterior payload (shade byte 0 — `entity_shade` runs after the classifier and
|
||||
/// re-asserts the ramped intensity byte the same frame; it skips only Bake parts). Both writes
|
||||
/// carry the tag's **alpha** field through, so a law change lands cleanly mid-fade. BOTH indoor
|
||||
/// laws carry the INTERIOR_FOG_BIT (Bake bakes it in): the reference fogs a unit by the unit's OWN
|
||||
/// interior classification, so an indoor day/night character keeps the room's fog — never the
|
||||
/// storm's near veil — while the exterior law returns it to the scene fog (wow-re
|
||||
/// `m2-unit-interior-fog.md`; the director's corridor-vs-porch walk-out). Every arm carries the
|
||||
/// part's rig field through (decision 0720): a skinned part keeps its palette across the
|
||||
/// indoor/outdoor transition.
|
||||
///
|
||||
/// `fade` is `Some` only while an appear/despawn ramp owns the part (live **or** pending), and it
|
||||
/// selects the law's BLEND twin instead of its steady material (decision 0755). It is the same
|
||||
/// [`crate::model_fade::FadeMaterials::material_for`] rule the ramp itself applies every frame, so
|
||||
/// the two writers produce the identical handle and can never fight, in either order.
|
||||
///
|
||||
/// Writing the material here rather than leaving it entirely to the ramp is what keeps the
|
||||
/// **material mode and the tag payload mode naming the same law at every instant** — the invariant
|
||||
/// `mesh_tag::describe` exists to catch a violation of (0355 broke exactly this way). Skipping it
|
||||
/// would leave a part that classifies indoors while still *pending* carrying a probe slot on the
|
||||
/// exterior material, where the shader decodes those bits as a ground-shade byte.
|
||||
fn write_part_law(
|
||||
law: AppliedLaw,
|
||||
lit: &mut InteriorLit,
|
||||
material: &mut MeshMaterial3d<WowModelMaterial>,
|
||||
tag: &mut MeshTag,
|
||||
force: bool,
|
||||
fade: Option<&crate::model_fade::FadeMaterials>,
|
||||
) -> bool {
|
||||
if lit.applied == Some(law) && !force {
|
||||
return false;
|
||||
}
|
||||
material.0 = match law {
|
||||
// The exterior AND day/night states share the exterior material — the difference is
|
||||
// the node's intensity target (the tag byte `entity_shade` ramps; 0354).
|
||||
AppliedLaw::Exterior | AppliedLaw::Matte => lit.exterior.clone(),
|
||||
AppliedLaw::Bake(_) => match &lit.kind {
|
||||
InteriorKind::Bake { material, .. } => material.clone(),
|
||||
InteriorKind::Matte => lit.exterior.clone(), // a matte part under a bake-law anchor
|
||||
},
|
||||
lit.applied = Some(law);
|
||||
let want = match fade {
|
||||
Some(fm) => fm.material_for(Some(&*lit), true).clone(),
|
||||
None => lit.steady_material().clone(),
|
||||
};
|
||||
if material.0 != want {
|
||||
material.0 = want;
|
||||
}
|
||||
tag.0 = match law {
|
||||
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
|
||||
AppliedLaw::Matte => {
|
||||
@@ -493,7 +552,6 @@ fn write_part_law(
|
||||
}
|
||||
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
|
||||
};
|
||||
lit.applied = Some(law);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -662,6 +720,7 @@ mod tests {
|
||||
|
||||
fn classifier_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Time>();
|
||||
world.init_resource::<WmoResidency>();
|
||||
world.init_resource::<Assets<WmoModel>>();
|
||||
world.init_resource::<Assets<AdtTile>>();
|
||||
@@ -829,6 +888,140 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The 0755 regression, at the anchor walk: an anchor whose parts are ALL mid-appear-fade
|
||||
/// still resolves its law. Pre-0755 the classifier's write query excluded fading parts, so
|
||||
/// such an anchor was skipped outright — a freshly-streamed indoor entity had no light law for
|
||||
/// the whole 2 s ramp, appeared under the exterior lane, and swapped laws in a single frame the
|
||||
/// instant the ramp latched (director-reported: "it swaps lighting in an instant once it's
|
||||
/// fully faded in", measured at 2.02 s after the fade armed).
|
||||
#[test]
|
||||
fn an_anchor_whose_parts_are_all_fading_still_resolves() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = classifier_world();
|
||||
let anchor = world.spawn(GlobalTransform::default()).id();
|
||||
let part = world
|
||||
.spawn((
|
||||
InteriorLit::new(InteriorKind::Matte, Handle::default()),
|
||||
ClassifiedBy(anchor),
|
||||
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
|
||||
MeshTag(crate::mesh_tag::alpha_bits(0.125)),
|
||||
crate::model_fade::RenderFade {
|
||||
started: 0.0,
|
||||
duration: 2.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
|
||||
assert!(
|
||||
world.get::<InteriorAnchor>(anchor).is_some(),
|
||||
"the anchor resolves during the ramp, not two seconds after it"
|
||||
);
|
||||
assert!(
|
||||
world.get::<InteriorLit>(part).unwrap().applied.is_some(),
|
||||
"and the part records the law it resolved to"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of 0755: writing a law onto a part whose ramp is live must carry the ramp's
|
||||
/// alpha through the payload rewrite (the classifier used to hardcode opaque, which is exactly
|
||||
/// why it had to be locked out), and must land the part on that law's BLEND twin — so the
|
||||
/// material mode and the tag payload mode name the same law at every instant, mid-ramp
|
||||
/// included.
|
||||
#[test]
|
||||
fn a_law_written_mid_ramp_keeps_the_alpha_and_takes_the_laws_blend_twin() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = classifier_world();
|
||||
let slot = world
|
||||
.resource_mut::<PropProbes>()
|
||||
.alloc_owned([Vec4::ZERO; 7])
|
||||
.unwrap();
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
let anchor = world
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
PropProbeSlot(slot),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Bake(slot),
|
||||
last_pos: Vec3::ZERO, // settled: the walk skips the ray, the drain does the work
|
||||
generation,
|
||||
kind_bake: true,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// The steady bake variant, and the probe-lit blend twin the ramp must feather on.
|
||||
let bake: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("ba000000-0000-4000-8000-00000000ba1e");
|
||||
let bake_blend: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("bb000000-0000-4000-8000-00000000b1e4");
|
||||
let exterior_blend: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("e0000000-0000-4000-8000-00000000b1e4");
|
||||
let mid_ramp = crate::mesh_tag::alpha_bits(0.25);
|
||||
let part = world
|
||||
.spawn((
|
||||
InteriorLit::new(
|
||||
InteriorKind::Bake {
|
||||
material: bake.clone(),
|
||||
center: Vec3::ZERO,
|
||||
},
|
||||
Handle::default(),
|
||||
),
|
||||
ClassifiedBy(anchor),
|
||||
// Spawned on the EXTERIOR blend twin, as a streamed part is before it classifies.
|
||||
MeshMaterial3d::<WowModelMaterial>(exterior_blend.clone()),
|
||||
MeshTag(mid_ramp),
|
||||
crate::model_fade::FadeMaterials {
|
||||
cutout: Handle::default(),
|
||||
blend: exterior_blend.clone(),
|
||||
bake_blend: Some(bake_blend.clone()),
|
||||
},
|
||||
crate::model_fade::RenderFade {
|
||||
started: 0.0,
|
||||
duration: 2.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
|
||||
let tag = world.get::<MeshTag>(part).unwrap().0;
|
||||
assert!(
|
||||
world.get::<InteriorLit>(part).unwrap().is_bake(),
|
||||
"the ramping part takes the room's bake law"
|
||||
);
|
||||
assert_eq!(
|
||||
tag,
|
||||
crate::mesh_tag::with_interior_probe(mid_ramp, slot),
|
||||
"the probe slot lands on top of the ramp's alpha, not over it"
|
||||
);
|
||||
assert_ne!(
|
||||
tag,
|
||||
crate::mesh_tag::probe_bits(slot),
|
||||
"the payload must not force a mid-ramp part opaque (the pre-0755 write)"
|
||||
);
|
||||
let material = world
|
||||
.get::<MeshMaterial3d<WowModelMaterial>>(part)
|
||||
.unwrap()
|
||||
.0
|
||||
.clone();
|
||||
assert_eq!(
|
||||
material, bake_blend,
|
||||
"a ramping part takes the law's PROBE-LIT blend twin — not the exterior one it \
|
||||
spawned on (its light would read as full outdoor intensity), and not the steady bake \
|
||||
variant (the ramp would stop feathering)"
|
||||
);
|
||||
assert_ne!(material, exterior_blend);
|
||||
assert_ne!(material, bake);
|
||||
}
|
||||
|
||||
/// The fade-latch edge: removing a part's `RenderFade` re-enqueues it for authoring — the
|
||||
/// event that closes every fade-exclusion window (0734 §3; the old settled-path sweep is
|
||||
/// gone, so this observer IS the convergence path).
|
||||
@@ -847,8 +1040,6 @@ mod tests {
|
||||
duration: 1.0,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
cutout: Handle::default(),
|
||||
blend: Handle::default(),
|
||||
});
|
||||
world
|
||||
.entity_mut(part)
|
||||
|
||||
@@ -44,21 +44,30 @@
|
||||
//!
|
||||
//! **Who writes it** — five systems share the channel, deconflicted *by design*, not by luck:
|
||||
//!
|
||||
//! 1. `model_fade::apply_render_fade` (appear/despawn ramps) owns an entity's **alpha field** (and
|
||||
//! material) while a `RenderFade` lives on it; the other alpha writers filter those entities out
|
||||
//! (`Without<RenderFade>`, `Without<PendingAppearFade>`). It writes through [`with_alpha`], so
|
||||
//! the shade and rig fields survive a fade.
|
||||
//! 2. `interior::classify_entity_interior` is the steady-state owner for interior-capable parts
|
||||
//! (`InteriorLit` holders) — and of [`INTERIOR_FOG_BIT`]: probe slot + fog bit on the Bake law,
|
||||
//! fog bit alone on the Matte law, plain opaque payload outdoors (dropping the shade byte —
|
||||
//! the shade writer below re-asserts it the same frame, ordered after). Its whole-payload
|
||||
//! writes go through [`with_interior_probe`] / [`with_exterior_reset`], which carry the rig
|
||||
//! field.
|
||||
//! 1. `model_fade::apply_render_fade` (appear/despawn ramps) owns an entity's **alpha field**, and
|
||||
//! its **material**, while a `RenderFade` lives on it; the other *alpha* writers filter those
|
||||
//! entities out (`Without<RenderFade>`, `Without<PendingAppearFade>`). It writes through
|
||||
//! [`with_alpha`], so the shade, probe-slot and rig fields survive a fade.
|
||||
//! 2. `interior::classify_entity_interior` is the owner of the **light law** for interior-capable
|
||||
//! parts (`InteriorLit` holders) — the payload's non-alpha fields and [`INTERIOR_FOG_BIT`]:
|
||||
//! probe slot + fog bit on the Bake law, fog bit alone on the Matte law, plain payload outdoors
|
||||
//! (dropping the shade byte — the shade writer below re-asserts it the same frame, ordered
|
||||
//! after). Its whole-payload writes go through [`with_interior_probe`] /
|
||||
//! [`with_exterior_reset`], which carry the rig **and alpha** fields.
|
||||
//!
|
||||
//! It is **not** filtered by fade state (decision 0755): the light law and the fade alpha are
|
||||
//! orthogonal, so 1 and 2 are deconflicted by *field*, not by lockout — the law follows a part
|
||||
//! through its appear/despawn ramp, and only the *material* defers to 1 while a fade is live
|
||||
//! (the fade picks the blend twin **of the law's family**, `FadeMaterials::material_for`). The
|
||||
//! old lockout is what made a streamed indoor unit ramp in under exterior light and then swap
|
||||
//! laws in a single frame the instant the ramp latched.
|
||||
//! 3. `debug_panel::apply_model_visibility` drives the small-prop distance fade (`DoodadFade`
|
||||
//! holders) + glow-card dimming; `DoodadFade` is **never attached** to a lit interior prop
|
||||
//! (spawn-time exclusion in `terrain_stream`), so 2 and 3 are disjoint.
|
||||
//! 4. `player::apply_self_model_fade` (the zoom-to-first-person feather) runs **after** 1–3 and wins
|
||||
//! on the self body submeshes' alpha while feathering; at α ≥ 1 it yields the channel back to 2.
|
||||
//! on the self body submeshes' alpha while feathering; on the frame it ends it restores the
|
||||
//! alpha field itself and hands the material back to the part's law (it cannot lean on 2 to
|
||||
//! re-opaque it — since 0755 that writer carries alpha through rather than forcing it).
|
||||
//! 5. `entity_shade::update_ground_shade` owns the **shade field** on entity M2 parts (decision
|
||||
//! 0173): read-modify-write via [`with_shade`], never touching alpha — so it composes with 1–4
|
||||
//! instead of racing them. It skips interior-classified parts (their payload is a colour) and
|
||||
@@ -108,22 +117,40 @@ pub(crate) const MAX_RIG_SLOTS: usize = 1 << 11;
|
||||
/// An interior-probe payload constructor for RIG-LESS spawns (the static-prop spawner): the slot
|
||||
/// in bits 6..=18 + an opaque alpha field + the [`INTERIOR_FOG_BIT`] (a probe payload always
|
||||
/// means the model stands indoors). Rig-carrying writers (the entity classifier) go through
|
||||
/// [`with_interior_probe`] instead, which preserves the rig field.
|
||||
/// [`with_interior_probe`] instead, which preserves the rig and alpha fields.
|
||||
pub(crate) fn probe_bits(slot: u16) -> u32 {
|
||||
INTERIOR_FOG_BIT | (u32::from(slot) << PROBE_SHIFT) | alpha_bits(1.0)
|
||||
}
|
||||
|
||||
/// Rewrite a tag as an interior-probe payload, preserving the rig field (and nothing else — the
|
||||
/// classifier owns the rest of the payload when it fires): the entity classifier's Bake-law write
|
||||
/// for skinned unit parts, whose rig slot must ride through the indoor/outdoor transitions.
|
||||
pub(crate) fn with_interior_probe(tag: u32, slot: u16) -> u32 {
|
||||
(tag & RIG_MASK) | probe_bits(slot)
|
||||
/// The alpha field a whole-payload rewrite carries through, with the whole-payload-`0`
|
||||
/// *untagged ⇒ opaque* sentinel materialized as opaque (the field is never legitimately `0` —
|
||||
/// [`alpha_bits`] floors at `1`, so a `0` here can only be the sentinel).
|
||||
///
|
||||
/// This is what lets the interior classifier **re-lane a part while a fade owns its alpha**: the
|
||||
/// law rewrites the payload, the ramp's alpha rides through untouched (decision 0755). Before it,
|
||||
/// the classifier's payload writes hardcoded opaque, which is why it had to be locked out of
|
||||
/// fading parts entirely — and why a freshly-streamed indoor entity spent its whole 2 s appear
|
||||
/// ramp on the exterior lane and swapped light laws in one frame when the ramp latched.
|
||||
fn carried_alpha(tag: u32) -> u32 {
|
||||
match tag & ALPHA_MASK {
|
||||
0 => ALPHA_MASK,
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a tag as a fresh opaque exterior payload, preserving the rig field: the classifier's
|
||||
/// outdoor reclaim (the shade writer re-asserts the shade byte the same frame, ordered after).
|
||||
/// Rewrite a tag as an interior-probe payload, preserving the rig and alpha fields (and nothing
|
||||
/// else — the classifier owns the rest of the payload when it fires): the entity classifier's
|
||||
/// Bake-law write for skinned unit parts, whose rig slot must ride through the indoor/outdoor
|
||||
/// transitions and whose fade alpha must ride through a mid-ramp re-lane.
|
||||
pub(crate) fn with_interior_probe(tag: u32, slot: u16) -> u32 {
|
||||
INTERIOR_FOG_BIT | (tag & RIG_MASK) | (u32::from(slot) << PROBE_SHIFT) | carried_alpha(tag)
|
||||
}
|
||||
|
||||
/// Rewrite a tag as a fresh exterior payload, preserving the rig and alpha fields: the
|
||||
/// classifier's outdoor reclaim (the shade writer re-asserts the shade byte the same frame,
|
||||
/// ordered after).
|
||||
pub(crate) fn with_exterior_reset(tag: u32) -> u32 {
|
||||
(tag & RIG_MASK) | alpha_bits(1.0)
|
||||
(tag & RIG_MASK) | carried_alpha(tag)
|
||||
}
|
||||
|
||||
/// The rig field of a part's spawn tag (decision 0720): the skin-palette rig slot, written once
|
||||
@@ -165,11 +192,7 @@ pub(crate) fn with_alpha(tag: u32, alpha: f32) -> u32 {
|
||||
/// [`alpha_bits`] floors at `1`), so materialize it as opaque — otherwise a non-zero shade byte
|
||||
/// would defeat the sentinel and the instance would decode alpha 0 (invisible).
|
||||
pub(crate) fn with_shade(tag: u32, shade: u8) -> u32 {
|
||||
let alpha = match tag & ALPHA_MASK {
|
||||
0 => ALPHA_MASK,
|
||||
a => a,
|
||||
};
|
||||
(tag & !(ALPHA_MASK | SHADE_MASK)) | alpha | (u32::from(shade) << SHADE_SHIFT)
|
||||
(tag & !(ALPHA_MASK | SHADE_MASK)) | carried_alpha(tag) | (u32::from(shade) << SHADE_SHIFT)
|
||||
}
|
||||
|
||||
/// Read back the shade byte of an exterior-payload tag (the shade writer's change gate).
|
||||
@@ -326,6 +349,39 @@ mod tests {
|
||||
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
|
||||
}
|
||||
|
||||
/// Decision 0755: the classifier's whole-payload rewrites carry the tag's ALPHA field, which
|
||||
/// is what lets a part change light law while a fade owns its ramp. Before this they wrote
|
||||
/// `alpha_bits(1.0)`, so the classifier had to be locked out of fading parts entirely — and a
|
||||
/// streamed indoor entity therefore had no interior law until its 2 s appear ramp latched.
|
||||
#[test]
|
||||
fn a_law_rewrite_carries_the_fade_alpha() {
|
||||
let mid_ramp = alpha_bits(0.25);
|
||||
// Into the interior lane and back out again, at a quarter alpha the whole way.
|
||||
let indoor = with_interior_probe(rig_bits(7) | mid_ramp, 1234);
|
||||
assert_eq!(
|
||||
indoor & ALPHA_MASK,
|
||||
mid_ramp,
|
||||
"the ramp survives the re-lane"
|
||||
);
|
||||
assert_eq!((indoor & PROBE_MASK) >> PROBE_SHIFT, 1234);
|
||||
assert_eq!(rig_of(indoor), 7);
|
||||
assert_eq!(indoor & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
|
||||
let outdoor = with_exterior_reset(indoor);
|
||||
assert_eq!(outdoor & ALPHA_MASK, mid_ramp, "and the reclaim too");
|
||||
assert_eq!(rig_of(outdoor), 7);
|
||||
assert_eq!(outdoor & (PROBE_MASK | INTERIOR_FOG_BIT), 0);
|
||||
// A settled (opaque) part is unaffected — the steady state still reads exactly as before.
|
||||
assert_eq!(
|
||||
with_interior_probe(alpha_bits(1.0), 1234),
|
||||
probe_bits(1234),
|
||||
"an opaque part's Bake payload is unchanged"
|
||||
);
|
||||
assert_eq!(with_exterior_reset(probe_bits(1234)), alpha_bits(1.0));
|
||||
// The untagged-⇒-opaque sentinel is materialized, never propagated as alpha 0 (invisible).
|
||||
assert_eq!(with_interior_probe(0, 1234) & ALPHA_MASK, ALPHA_MASK);
|
||||
assert_eq!(with_exterior_reset(0) & ALPHA_MASK, ALPHA_MASK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_shade_materializes_the_untagged_sentinel_as_opaque() {
|
||||
// Shading an untagged (payload 0) instance must not defeat the "0 ⇒ opaque" rule by making
|
||||
|
||||
@@ -358,12 +358,14 @@ mod tests {
|
||||
key_type: LOCK_KEY_SKILL,
|
||||
index: 3,
|
||||
skill: 0,
|
||||
action: 0,
|
||||
};
|
||||
let mut keyed = [LockSlot::default(); MAX_LOCK_SLOTS];
|
||||
keyed[0] = LockSlot {
|
||||
key_type: LOCK_KEY_ITEM,
|
||||
index: 3,
|
||||
skill: 0,
|
||||
action: 0,
|
||||
};
|
||||
let locks = LockCatalog::from_rows([(38, vein), (40, keyed)]);
|
||||
assert!(tracked_resource(1 << 2, 38, &locks), "mining bit lights it");
|
||||
|
||||
@@ -96,9 +96,13 @@ pub fn doodad_fade_alpha(radius: f32, horiz_dist: f32) -> f32 {
|
||||
/// *distance* fade: both drive the **same** per-instance render-alpha channel (the `MeshTag` the shader
|
||||
/// reads, with a cutout↔blend material swap while `α < 1`), matching the reference's single render-alpha
|
||||
/// slot (`CM2Model+0x19c`) written by separate sources for disjoint object categories (map doodads →
|
||||
/// distance fade; CGObjects → appear fade). [`apply_render_fade`] drives it; [`crate::interior`]'s
|
||||
/// classifier yields the channel while a fade is live and reclaims it (floor colour / steady material)
|
||||
/// once the fade latches — so a fading entity uses its exterior look, then settles into its room.
|
||||
/// distance fade; CGObjects → appear fade). [`apply_render_fade`] drives it.
|
||||
///
|
||||
/// It owns the part's **alpha** and its **material** while it lives; [`crate::interior`]'s
|
||||
/// classifier keeps owning the part's **light law** right through the ramp (decision 0755), and
|
||||
/// the fade reads that law each frame to pick the blend twin of the right family
|
||||
/// ([`FadeMaterials::material_for`]) — so an entity that streams in indoors ramps up already lit by
|
||||
/// its room instead of appearing under exterior light and snapping to the room's when it latches.
|
||||
///
|
||||
/// Built general (`from`/`to`/`duration`) so the **despawn fade-out** (our stream-out look — the
|
||||
/// binary has no teardown fade, see [`DespawnFade`]) is a `{from: α, to: 0}` instance + a
|
||||
@@ -110,13 +114,9 @@ pub struct RenderFade {
|
||||
pub started: f32,
|
||||
/// Fade length in seconds. Appear = [`APPEAR_FADE_SECS`].
|
||||
pub duration: f32,
|
||||
/// Cubic ramp endpoints. Appear `0 → 1`; despawn (future) `α → 0`.
|
||||
/// Cubic ramp endpoints. Appear `0 → 1`; despawn `α → 0`.
|
||||
pub from: f32,
|
||||
pub to: f32,
|
||||
/// The steady (opaque/alpha-test) material and its `AlphaMode::Blend` twin — swapped while `α < 1`,
|
||||
/// exactly as [`DoodadFade`] does (the blend twin is what the shader feathers; the cutout ignores `α`).
|
||||
pub cutout: Handle<WowModelMaterial>,
|
||||
pub blend: Handle<WowModelMaterial>,
|
||||
}
|
||||
|
||||
/// The reference's appear-fade duration — `FadeTo(1.0, 2000 ms)` (`wow-5875-re` object-layer/`appear-fade`:
|
||||
@@ -125,18 +125,12 @@ pub const APPEAR_FADE_SECS: f32 = 2.0;
|
||||
|
||||
impl RenderFade {
|
||||
/// A spawn appear-fade armed at `now`: `α = t³` from 0 → 1 over [`APPEAR_FADE_SECS`].
|
||||
pub fn appear(
|
||||
now: f32,
|
||||
cutout: Handle<WowModelMaterial>,
|
||||
blend: Handle<WowModelMaterial>,
|
||||
) -> Self {
|
||||
pub fn appear(now: f32) -> Self {
|
||||
Self {
|
||||
started: now,
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
cutout,
|
||||
blend,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,10 +172,17 @@ pub fn self_model_fade_alpha(dist: f32, nearclip: f32, window: f32) -> f32 {
|
||||
0.5 * (1.0 - (std::f32::consts::PI * d / window).cos())
|
||||
}
|
||||
|
||||
/// Drive every live [`RenderFade`]: ramp the cubic alpha into the per-instance `MeshTag`, swap to the
|
||||
/// blend material while feathering (the cutout ignores `α`), and drop the component once an appear fade
|
||||
/// latches opaque (handing the channel back to [`crate::interior`]). Only freshly-streamed entities carry
|
||||
/// a `RenderFade` — it's removed after [`APPEAR_FADE_SECS`] — so the steady-state cost is nil.
|
||||
/// Drive every live [`RenderFade`]: ramp the cubic alpha into the per-instance `MeshTag`, ride the
|
||||
/// blend twin of the part's **current light law** while feathering (the cutout ignores `α`), and
|
||||
/// drop the component once an appear fade latches opaque (handing the material back to
|
||||
/// [`crate::interior`] on the law's steady variant). Only freshly-streamed or streaming-out
|
||||
/// entities carry a `RenderFade` — it's removed after [`APPEAR_FADE_SECS`] — so the steady-state
|
||||
/// cost is nil.
|
||||
///
|
||||
/// The material is resolved from [`FadeMaterials`] + the live [`crate::interior::InteriorLit`]
|
||||
/// every frame rather than from a pair latched at arm time (decision 0755), so a part that
|
||||
/// classifies (or re-classifies) *during* its ramp follows its room's light immediately instead of
|
||||
/// finishing the ramp on whichever law happened to hold when the fade armed.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn apply_render_fade(
|
||||
time: Res<Time>,
|
||||
@@ -192,10 +193,12 @@ pub(crate) fn apply_render_fade(
|
||||
&mut MeshTag,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
Option<&crate::doodad_anim::MatAnim>,
|
||||
Option<&FadeMaterials>,
|
||||
Option<&crate::interior::InteriorLit>,
|
||||
)>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, fade, mut tag, mut mat, anim) in &mut q {
|
||||
for (entity, fade, mut tag, mut mat, anim, fm, lit) in &mut q {
|
||||
let t = if fade.duration > 0.0 {
|
||||
(now - fade.started) / fade.duration
|
||||
} else {
|
||||
@@ -215,13 +218,15 @@ pub(crate) fn apply_render_fade(
|
||||
tag.0 = bits;
|
||||
}
|
||||
// Feather on the blend twin while translucent; the cutout (opaque/alpha-test) ignores `α`.
|
||||
let want = if alpha < 1.0 {
|
||||
&fade.blend
|
||||
} else {
|
||||
&fade.cutout
|
||||
};
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
// Both come from the part's CURRENT law — an indoor part feathers probe-lit and settles on
|
||||
// the probe-lit steady material, with no law change at the latch. A part without
|
||||
// `FadeMaterials` has no twin to swap to (it never fades in practice — every arming site
|
||||
// pairs the two); its alpha still ramps, so it can never hang invisible.
|
||||
if let Some(fm) = fm {
|
||||
let want = fm.material_for(lit, alpha < 1.0);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
}
|
||||
// Appear fade reached opaque: latch and release the channel back to the interior classifier.
|
||||
// `try_remove`: wire-owned entity — see the lifetime contract in `arm_appear_fade`.
|
||||
@@ -239,9 +244,6 @@ pub(crate) fn apply_render_fade(
|
||||
/// (Decision 0032.)
|
||||
#[derive(Component, Clone)]
|
||||
pub struct PendingAppearFade {
|
||||
/// The steady (opaque/alpha-test) material + its `AlphaMode::Blend` twin — handed to the [`RenderFade`].
|
||||
pub cutout: Handle<WowModelMaterial>,
|
||||
pub blend: Handle<WowModelMaterial>,
|
||||
/// `Time::elapsed_secs` at attach — the backstop-timeout origin.
|
||||
pub since: f32,
|
||||
}
|
||||
@@ -317,8 +319,10 @@ pub(crate) fn arm_appear_fade(
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
let shown = !screen.covering();
|
||||
let mut armed = 0usize;
|
||||
for (entity, pending) in &q {
|
||||
if shown || now - pending.since > PENDING_TIMEOUT_SECS {
|
||||
armed += 1;
|
||||
// `try_*`, like every fade command here: these entities are **wire-owned** — a net
|
||||
// destroy can apply at any sync point between this system's query and its own
|
||||
// commands, so fade bookkeeping on an already-dead entity is a no-op, never an error.
|
||||
@@ -326,11 +330,7 @@ pub(crate) fn arm_appear_fade(
|
||||
// and a same-frame wire despawn beat this insert — decision 0200.)
|
||||
commands
|
||||
.entity(entity)
|
||||
.try_insert(RenderFade::appear(
|
||||
now,
|
||||
pending.cutout.clone(),
|
||||
pending.blend.clone(),
|
||||
))
|
||||
.try_insert(RenderFade::appear(now))
|
||||
.try_remove::<PendingAppearFade>();
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,13 @@ pub(crate) fn arm_appear_fade(
|
||||
}
|
||||
}
|
||||
}
|
||||
// `WOW_INTERIOR_LOG=1`: the appear-fade's arming instant — the reference time the interior
|
||||
// classifier's `[interior]` lines are read against. The seam between the two is where an
|
||||
// indoor entity's light law used to land only AFTER the ramp latched (2 s of exterior light,
|
||||
// then a pop); a run where the two instants coincide is the observable that closes it.
|
||||
if armed > 0 && std::env::var_os("WOW_INTERIOR_LOG").is_some() {
|
||||
eprintln!("[fade-arm] t {now:.2} armed {armed} parts (world shown: {shown})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a [`UnitAppearFade::Live`] once its mirrored ramp completes ([`APPEAR_FADE_SECS`] after
|
||||
@@ -363,9 +370,15 @@ pub(crate) fn retire_unit_appear_fade(
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent per-submesh fade material pair, so the **despawn** fade-out can re-arm a [`RenderFade`]
|
||||
/// after the appear fade removed itself: `cutout` = the steady opaque/alpha-test material, `blend` = its
|
||||
/// `AlphaMode::Blend` twin. Attached to every fadeable entity submesh at spawn.
|
||||
/// Persistent per-submesh fade material set — **the** source of truth for which material a part
|
||||
/// draws with, across every fade in the client: `cutout` = the steady opaque/alpha-test material,
|
||||
/// `blend` = its `AlphaMode::Blend` twin. Attached to every fadeable entity submesh at spawn.
|
||||
///
|
||||
/// Held persistently rather than copied into each fade (decision 0755): a latched pair is a
|
||||
/// snapshot of the part's light law at arm time, and a law that changes mid-ramp — a streamed
|
||||
/// indoor unit classifying while it appears, an NPC crossing a doorway as it fades out — cannot be
|
||||
/// expressed by one. Every fade instead resolves its material *per frame* through
|
||||
/// [`Self::material_for`].
|
||||
#[derive(Component, Clone)]
|
||||
pub struct FadeMaterials {
|
||||
pub cutout: Handle<WowModelMaterial>,
|
||||
@@ -376,6 +389,31 @@ pub struct FadeMaterials {
|
||||
pub bake_blend: Option<Handle<WowModelMaterial>>,
|
||||
}
|
||||
|
||||
impl FadeMaterials {
|
||||
/// The material a part should draw with, given its current interior law (`lit` — `None` for a
|
||||
/// part the classifier doesn't light) and whether it is still `feathering` (`α < 1`).
|
||||
///
|
||||
/// The two axes are independent and this is the one place they compose, so every fade writer —
|
||||
/// the appear/despawn ramp ([`apply_render_fade`]) and the self-avatar zoom feather
|
||||
/// ([`crate::player::apply_self_model_fade`]) — agrees on the answer regardless of which runs
|
||||
/// last in the frame. Feathering picks the law's **blend** twin (the probe-lit one indoors, so
|
||||
/// the room's light rides the fade rather than jumping to the exterior twin's outdoor
|
||||
/// intensity — 0355); settled hands back the law's **steady** material, which is the
|
||||
/// classifier's own choice ([`crate::interior::InteriorLit::steady_material`]).
|
||||
pub fn material_for<'a>(
|
||||
&'a self,
|
||||
lit: Option<&'a crate::interior::InteriorLit>,
|
||||
feathering: bool,
|
||||
) -> &'a Handle<WowModelMaterial> {
|
||||
match (feathering, lit) {
|
||||
(true, Some(l)) if l.is_bake() => self.bake_blend.as_ref().unwrap_or(&self.blend),
|
||||
(true, _) => &self.blend,
|
||||
(false, Some(l)) => l.steady_material(),
|
||||
(false, None) => &self.cutout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a streamed entity (the parent) that went out of range: instead of popping it out,
|
||||
/// [`apply_despawn_fade`] fades it out then despawns it. **Our** stream-out look, not a verified
|
||||
/// mechanism — the wow-re teardown RE found no fade-out in the binary (a *destroyed* object pops
|
||||
@@ -406,7 +444,7 @@ pub(crate) fn apply_despawn_fade(
|
||||
mut commands: Commands,
|
||||
mut q: Query<(Entity, &mut DespawnFade)>,
|
||||
children_of: Query<&Children>,
|
||||
fm: Query<(&FadeMaterials, Option<&crate::interior::InteriorLit>)>,
|
||||
fm: Query<(), With<FadeMaterials>>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (parent, mut df) in &mut q {
|
||||
@@ -432,17 +470,14 @@ fn arm_despawn_descendants(
|
||||
now: f32,
|
||||
commands: &mut Commands,
|
||||
children_of: &Query<&Children>,
|
||||
fm: &Query<(&FadeMaterials, Option<&crate::interior::InteriorLit>)>,
|
||||
fm: &Query<(), With<FadeMaterials>>,
|
||||
any: &mut bool,
|
||||
) {
|
||||
if let Ok((m, lit)) = fm.get(entity) {
|
||||
if fm.contains(entity) {
|
||||
*any = true;
|
||||
// A bake-classified part fades out on the probe-lit blend twin — its room light rides the
|
||||
// feather instead of jumping to the exterior twin's outdoor intensity (0355).
|
||||
let blend = match (&m.bake_blend, lit) {
|
||||
(Some(bake), Some(lit)) if lit.is_bake() => bake.clone(),
|
||||
_ => m.blend.clone(),
|
||||
};
|
||||
// No material is chosen here: `apply_render_fade` resolves it per frame from the part's
|
||||
// live law (0755), so a bake-classified part fades out probe-lit — and keeps doing so if
|
||||
// it re-classifies mid-ramp, which a pair latched at this instant could not express.
|
||||
// `try_*`: a child (a held item mid-re-resolve, a gear swap) can be despawned by its own
|
||||
// owner in the same frame — the lifetime contract in `arm_appear_fade`.
|
||||
commands
|
||||
@@ -452,8 +487,6 @@ fn arm_despawn_descendants(
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 1.0,
|
||||
to: 0.0,
|
||||
cutout: m.cutout.clone(),
|
||||
blend,
|
||||
})
|
||||
.try_remove::<PendingAppearFade>();
|
||||
}
|
||||
@@ -481,13 +514,7 @@ mod tests {
|
||||
world.init_resource::<Time>();
|
||||
// Default screen = not covering = "world shown" — every pending fade arms this frame.
|
||||
world.init_resource::<crate::loading_screen::LoadingScreen>();
|
||||
let doomed = world
|
||||
.spawn(PendingAppearFade {
|
||||
cutout: Handle::default(),
|
||||
blend: Handle::default(),
|
||||
since: 0.0,
|
||||
})
|
||||
.id();
|
||||
let doomed = world.spawn(PendingAppearFade { since: 0.0 }).id();
|
||||
|
||||
let mut state: SystemState<(
|
||||
Res<Time>,
|
||||
@@ -505,6 +532,57 @@ mod tests {
|
||||
assert!(world.get_entity(doomed).is_err(), "stays despawned");
|
||||
}
|
||||
|
||||
/// Decision 0755: one law-aware material rule, shared by every fade writer. A bake-classified
|
||||
/// part feathers probe-lit and settles probe-lit; everything else rides the exterior pair. The
|
||||
/// pair is resolved per frame from the part's LIVE law, so a part that classifies during its
|
||||
/// ramp is lit by its room for the rest of it instead of finishing on the law that happened to
|
||||
/// hold when the fade armed.
|
||||
#[test]
|
||||
fn the_material_rule_follows_the_law_not_the_arm_instant() {
|
||||
use crate::interior::{InteriorKind, InteriorLit};
|
||||
|
||||
let cutout: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("c0000000-0000-4000-8000-000000000001");
|
||||
let blend: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("b1000000-0000-4000-8000-000000000002");
|
||||
let bake: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("ba000000-0000-4000-8000-000000000003");
|
||||
let bake_blend: Handle<WowModelMaterial> =
|
||||
bevy::asset::uuid_handle!("bb000000-0000-4000-8000-000000000004");
|
||||
let fm = FadeMaterials {
|
||||
cutout: cutout.clone(),
|
||||
blend: blend.clone(),
|
||||
bake_blend: Some(bake_blend.clone()),
|
||||
};
|
||||
|
||||
// Unclassified (no `InteriorLit` at all — a WMO-display part): the exterior pair.
|
||||
assert_eq!(*fm.material_for(None, true), blend);
|
||||
assert_eq!(*fm.material_for(None, false), cutout);
|
||||
|
||||
// Bake-CAPABLE but not yet resolved indoors (the state a part spawns in): exterior pair.
|
||||
let kind = InteriorKind::Bake {
|
||||
material: bake.clone(),
|
||||
center: Vec3::ZERO,
|
||||
};
|
||||
let unresolved = InteriorLit::new(kind.clone(), cutout.clone());
|
||||
assert_eq!(*fm.material_for(Some(&unresolved), true), blend);
|
||||
assert_eq!(*fm.material_for(Some(&unresolved), false), cutout);
|
||||
|
||||
// The law flips to the room's bake MID-RAMP: the very next frame feathers probe-lit, and
|
||||
// the latch settles on the bake variant — no law change, and so no pop, at the latch.
|
||||
let lit = InteriorLit::applied_bake_for_test(kind, cutout.clone());
|
||||
assert_eq!(*fm.material_for(Some(&lit), true), bake_blend);
|
||||
assert_eq!(*fm.material_for(Some(&lit), false), bake);
|
||||
|
||||
// A part with no bake blend twin authored falls back to the exterior blend rather than
|
||||
// dropping the feather.
|
||||
let no_twin = FadeMaterials {
|
||||
bake_blend: None,
|
||||
..fm.clone()
|
||||
};
|
||||
assert_eq!(*no_twin.material_for(Some(&lit), true), blend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trees_and_buildings_never_fade() {
|
||||
// radius > 7.0 → always 1.0 regardless of distance.
|
||||
|
||||
@@ -597,23 +597,26 @@ fn apply_self_fade_to_descendants(
|
||||
if let Ok((fm, mut tag, mut mat, mut vis, lit)) = parts.get_mut(entity) {
|
||||
if alpha >= 1.0 {
|
||||
// The release edge (runs once, on the frame the fade ends — decision 0213): un-hide,
|
||||
// and hand the material/tag channel back. A part the classifier lights is enqueued so
|
||||
// it re-authors the correct interior/exterior state next classifier run (0734's
|
||||
// convergence queue); a part without a classifier (some attach models) gets the
|
||||
// direct restore — the same completion write `apply_render_fade` uses.
|
||||
// restore the alpha field this system owns, and hand the material back to the part's
|
||||
// law. The alpha restore is unconditional: the classifier's payload writes carry the
|
||||
// tag's alpha through since 0755 (that is what lets a part re-lane mid-fade), so
|
||||
// leaning on its re-author to *also* re-opaque the avatar — as this used to — would
|
||||
// leave it stuck translucent, the exact 0213 bug.
|
||||
if *vis != Visibility::Inherited {
|
||||
*vis = Visibility::Inherited;
|
||||
}
|
||||
let bits = crate::mesh_tag::with_alpha(tag.0, 1.0);
|
||||
if tag.0 != bits {
|
||||
tag.0 = bits;
|
||||
}
|
||||
let want = fm.material_for(lit, false);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
// A classifier-lit part is still enqueued so the next run re-asserts its full payload
|
||||
// (probe slot / fog bit) over whatever this feather episode wrote — 0734's queue.
|
||||
if lit.is_some() {
|
||||
reauthor.0.push(entity);
|
||||
} else {
|
||||
let bits = crate::mesh_tag::with_alpha(tag.0, 1.0);
|
||||
if tag.0 != bits {
|
||||
tag.0 = bits;
|
||||
}
|
||||
if mat.0 != fm.cutout {
|
||||
mat.0 = fm.cutout.clone();
|
||||
}
|
||||
}
|
||||
} else if alpha <= 0.0 {
|
||||
// First-person: hide outright. Leave tag/material to the classifier (not drawn anyway).
|
||||
@@ -634,11 +637,9 @@ fn apply_self_fade_to_descendants(
|
||||
// A bake-classified part feathers on the PROBE-lit blend twin — the room light
|
||||
// rides the fade (the tag re-lane keeps the slot alongside the alpha, 0355); the
|
||||
// exterior twin at shade byte 0 read as full outdoor intensity deep indoors
|
||||
// (director-caught, 2026-07-13).
|
||||
let want = match (&fm.bake_blend, &lit) {
|
||||
(Some(bake), Some(l)) if l.is_bake() => bake,
|
||||
_ => &fm.blend,
|
||||
};
|
||||
// (director-caught, 2026-07-13). Shared with the appear/despawn ramp since 0755, so
|
||||
// the two can never disagree about which twin a law wants.
|
||||
let want = fm.material_for(lit, true);
|
||||
if mat.0 != *want {
|
||||
mat.0 = want.clone();
|
||||
}
|
||||
|
||||
+127
-163
@@ -5,6 +5,7 @@
|
||||
//! resources and the plugin) along the state-vs-input seam; the systems here are registered by
|
||||
//! [`super::TargetPlugin`] in the target chain after the hover picks and the cursor classifier.
|
||||
|
||||
use super::lock::GoLockInputs;
|
||||
use super::*;
|
||||
|
||||
/// The **right-click cursor-payload leg** — the reference's WorldFrame click router (`0x481f60`
|
||||
@@ -141,11 +142,15 @@ pub(super) fn act_on_right_click(
|
||||
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
|
||||
mut emote: MessageWriter<crate::creature_anim::EmoteAnim>,
|
||||
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
|
||||
// The GameObject lock-routing inputs (decisions 0239 + 0545) as one [`GoLockInputs`]
|
||||
// The GameObject lock-routing inputs (decisions 0239 / 0545 / 0752) as one [`GoLockInputs`]
|
||||
// (the 16-SystemParam ceiling).
|
||||
mut go_inputs: GoLockInputs,
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
stores: Query<&ObjectStore>,
|
||||
// `[0xb700e4]`'s mirror — the skin leg's spell, already gated by the classifier (0752).
|
||||
learned: Res<crate::ui_action::LearnedAbilities>,
|
||||
// The GO leg needs the object's stored state too (the Action gate, decision 0752); the unit
|
||||
// legs ignore the second member.
|
||||
stores: Query<(&ObjectStore, Option<&crate::go_anim::GoAnim>)>,
|
||||
// One tuple param (the 16-SystemParam ceiling): the red-error keys + the reason-coded cast
|
||||
// line (the opener cast's local totem refusal, decision 0552) + the loot-target latch
|
||||
// the loot branch arms (decision 0515), and the mailbox session the mailbox branch opens
|
||||
@@ -163,47 +168,57 @@ pub(super) fn act_on_right_click(
|
||||
}
|
||||
// A GameObject is the nearest thing under the cursor → use it (decision 0236), and never fall
|
||||
// through to unit handling: a GO is not selectable, and a right-click on it acts on the GO or
|
||||
// does nothing (out of range / not usable). `classify_cursor` already resolved highlightable +
|
||||
// usable into the cursor — **any** non-Point GO cursor that isn't grayed (the Interact gear, or a
|
||||
// data-named Mail / Mine / GatherHerbs / PickLock) means highlightable AND usable (wow-re cursor-
|
||||
// system §4a: mode 0 ⇔ not highlightable, Unable* ⇔ not usable); Point or unable ⇒ don't send.
|
||||
// The action itself is the lock split ([`resolve_go_action`]), independent of the cursor shape —
|
||||
// which is exactly why the gate can no longer key on the Interact gear alone (that coupling was
|
||||
// decision 0243's known interim). The USE is verified for the right button (wow-re
|
||||
// cursor-system.md §6); a left-click USE folds in with the dispatched RE verdict.
|
||||
// does nothing. The reference's `OnUse 0x5f8660` gates on the same two predicates the cursor
|
||||
// computes (§4a/§8.7): **highlightable** first — false is a silent no-op, which for us is
|
||||
// `cursor.kind == Point` — then **usable**, whose failure toasts an error and sends nothing.
|
||||
//
|
||||
// `usable`'s two arms we model land in different places, so the routing is split accordingly
|
||||
// (decision 0752): the **lock** arm comes back out of [`resolve_go_action`] as
|
||||
// `GoAction::Refuse`, which carries §8.8's toast; the **range** arm is `cursor.unable`, which
|
||||
// suppresses the send with no toast (the reference auto-walks there instead — `0x610300`, also
|
||||
// no packet). The lock arm runs first in `0x5f3130`, and it does here too: a `Refuse` toasts
|
||||
// even when we are also out of range.
|
||||
if go_is_nearest(&hovered, &hovered_object) {
|
||||
if cursor.kind != cursor_mode::CursorKind::Point && !cursor.unable {
|
||||
if cursor.kind != cursor_mode::CursorKind::Point {
|
||||
if let Some(guid) = hovered_object.guid {
|
||||
let go = hovered_object
|
||||
.target
|
||||
.and_then(|e| stores.get(e).ok())
|
||||
.map(|(s, anim)| (s, crate::go_anim::go_state(anim, s)));
|
||||
// Mailbox (GO type 19): open the mail window client-side (decision 0544), BEFORE the
|
||||
// lock fork (a mailbox is never locked). The wow-re §5 confirms the MAILBOX use
|
||||
// handler overrides the shared use-sender to a LOCAL open — it sends NO packet (no
|
||||
// CMSG_GAMEOBJ_USE); the window's own MAIL_SHOW → CheckInbox drives the first
|
||||
// CMSG_GET_MAIL_LIST. Re-clicking just re-shows (the session is already set).
|
||||
let go_store = hovered_object.target.and_then(|e| stores.get(e).ok());
|
||||
if go_store
|
||||
.is_some_and(|s| s.0.gameobject_type_id() == cursor_mode::GO_TYPE_MAILBOX)
|
||||
if go.is_some_and(|(s, _)| s.0.gameobject_type_id() == cursor_mode::GO_TYPE_MAILBOX)
|
||||
{
|
||||
debug!("right-click mailbox: open mail window {guid:#x}");
|
||||
mail.click(guid);
|
||||
if !cursor.unable {
|
||||
debug!("right-click mailbox: open mail window {guid:#x}");
|
||||
mail.click(guid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Branch on the lock (decisions 0239 + 0545): a lockless GameObject is USEd; a
|
||||
// lockable one casts a known OPEN_LOCK spell at it; an unopenable lock shows the
|
||||
// client-local red toast — "Requires Herbalism", "Requires Mining 100", "Requires
|
||||
// <key item>" — and sends nothing (the ref's validate/error block `0x5f3427..`
|
||||
// fires `DisplayError` with no packet; wow-re cursor-system.md §8.4/§8.8).
|
||||
// Branch on the lock (decisions 0239 / 0545 / 0752): a lockless GameObject is USEd;
|
||||
// a lockable one casts the opener (a known OPEN_LOCK spell, or a carried key's own
|
||||
// ON_USE) at it; an unopenable lock shows the client-local red toast — "The door is
|
||||
// locked.", "Requires Herbalism", "Requires Mining 100", "Requires <key item>" —
|
||||
// and sends nothing (the ref's validate/error block `0x5f3427..` fires
|
||||
// `DisplayError` with no packet; wow-re cursor-system.md §8.4/§8.8).
|
||||
let me_store = self_player
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(e, _, _)| stores.get(e).ok());
|
||||
.and_then(|(e, _, _)| stores.get(e).ok())
|
||||
.map(|(s, _)| s);
|
||||
match resolve_go_action(
|
||||
guid,
|
||||
&mut go_inputs,
|
||||
&player_actions.spells,
|
||||
go_store,
|
||||
go,
|
||||
me_store,
|
||||
&net,
|
||||
) {
|
||||
GoAction::Use if cursor.unable => {}
|
||||
GoAction::OpenLock(_) if cursor.unable => {}
|
||||
GoAction::Use => {
|
||||
debug!("right-click gameobject use: {guid:#x}");
|
||||
let _ = net.0.send(ClientCommand::GameObjUse { guid });
|
||||
@@ -255,7 +270,7 @@ pub(super) fn act_on_right_click(
|
||||
// base mode is Pickup(8), which a live vendor also shows, so the kind alone can't name loot.
|
||||
let loot = stores
|
||||
.get(entity)
|
||||
.is_ok_and(|s| s.0.unit_is_dead() && s.0.unit_lootable());
|
||||
.is_ok_and(|(s, _)| s.0.unit_is_dead() && s.0.unit_lootable());
|
||||
let me = self_player.single().ok();
|
||||
// The one SetSelection law ([`scan::commit`]): dedup + selection + the engaged-switch
|
||||
// stop→select→re-swing. The Attack cursor kind is Attack `0x5ecb70`'s new-target validation
|
||||
@@ -276,7 +291,7 @@ pub(super) fn act_on_right_click(
|
||||
// order — but the melee auto-draw and the swing never happen; the red
|
||||
// ERR_ATTACK_MOUNTED line shows instead.
|
||||
if crate::ui_action::attack_mounted_refusal(
|
||||
me.and_then(|(e, _, _)| stores.get(e).ok()),
|
||||
me.and_then(|(e, _, _)| stores.get(e).ok()).map(|(s, _)| s),
|
||||
&mut ui_error_keys,
|
||||
) {
|
||||
// refused — selection stands, no swing
|
||||
@@ -317,31 +332,17 @@ pub(super) fn act_on_right_click(
|
||||
} else if cursor.kind == cursor_mode::CursorKind::Skin {
|
||||
// A dead, unlootable, SKINNABLE corpse (the classifier's own gate): cast our known
|
||||
// Skinning spell at it — the unit-side mirror of the GO lock split (0239; decision 0437's
|
||||
// gathering finish). The spell is discovered from the known set (`Effect[0] ==
|
||||
// SPELL_EFFECT_SKINNING`), never hardcoded — the same no-literal law as the OPEN_LOCK
|
||||
// scan; a player without Skinning sends nothing (the "requires" toast is 0239's own
|
||||
// later-polish note). Range rides the cursor's melee-reach gray, like loot.
|
||||
// gathering finish). The spell comes from the reference's own learn-time latch
|
||||
// ([`crate::ui_action::LearnedAbilities`] = `[0xb700e4]`, decision 0752), which is the same
|
||||
// thing the classifier gated the Skin cursor on — so reaching here at all means we know it.
|
||||
// Range rides the cursor's melee-reach gray, like loot.
|
||||
if !cursor.unable {
|
||||
let skinner = go_inputs.spells.as_ref().and_then(|s| {
|
||||
player_actions
|
||||
.spells
|
||||
.iter()
|
||||
.find(|&&id| {
|
||||
s.catalog
|
||||
.get(id)
|
||||
.is_some_and(|d| d.effect_1 == benilla_formats::SPELL_EFFECT_SKINNING)
|
||||
})
|
||||
.copied()
|
||||
});
|
||||
match skinner {
|
||||
Some(spell_id) => {
|
||||
debug!("right-click skin: {guid:#x} (spell {spell_id})");
|
||||
let _ = net.0.send(ClientCommand::CastSpell {
|
||||
spell_id,
|
||||
target: Some(guid),
|
||||
});
|
||||
}
|
||||
None => debug!("right-click skin: {guid:#x} — no known skinning spell"),
|
||||
if let Some(spell_id) = learned.skinning {
|
||||
debug!("right-click skin: {guid:#x} (spell {spell_id})");
|
||||
let _ = net.0.send(ClientCommand::CastSpell {
|
||||
spell_id,
|
||||
target: Some(guid),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if !cursor.unable {
|
||||
@@ -352,7 +353,7 @@ pub(super) fn act_on_right_click(
|
||||
// the NPC's own flags to split them (decision 0604).
|
||||
let npc_flags = stores
|
||||
.get(entity)
|
||||
.map(|s| s.0.unit_npc_flags())
|
||||
.map(|(s, _)| s.0.unit_npc_flags())
|
||||
.unwrap_or(0);
|
||||
if let Some(cmd) = interact_command(cursor.kind, guid, npc_flags) {
|
||||
debug!("right-click interact: {guid:#x} ({:?})", cursor.kind);
|
||||
@@ -370,28 +371,14 @@ pub(super) fn act_on_right_click(
|
||||
}
|
||||
}
|
||||
|
||||
/// The GameObject lock chain's full data set, as ONE [`SystemParam`] (decisions 0239 + 0545):
|
||||
/// the ask-once GO-template cache, Lock.dbc + LockType.dbc, the spell + skill-line catalogs, and
|
||||
/// the ask-once item-template cache (key-item names for the "Requires <key>" toast). The Option
|
||||
/// members are absent without client data.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct GoLockInputs<'w> {
|
||||
templates: Res<'w, crate::go_templates::GameObjectTemplates>,
|
||||
locks: Option<Res<'w, crate::go_templates::Locks>>,
|
||||
lock_types: Option<Res<'w, crate::go_templates::LockTypes>>,
|
||||
spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
skill_lines: Option<Res<'w, crate::ui_spellbook::SkillLines>>,
|
||||
items: ResMut<'w, crate::items::Items>,
|
||||
}
|
||||
|
||||
/// The right-click action a hovered GameObject resolves to (decisions 0239 + 0545) — chosen by
|
||||
/// its lock.
|
||||
/// The right-click action a hovered GameObject resolves to (decisions 0239 / 0545 / 0752) — chosen
|
||||
/// by its lock, through the shared chain in [`super::lock`].
|
||||
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 known `OPEN_LOCK` spell at the object (chest / vein / herb / a
|
||||
/// picked lock).
|
||||
/// 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.
|
||||
OpenLock(u32),
|
||||
/// 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.
|
||||
@@ -400,9 +387,6 @@ enum GoAction {
|
||||
|
||||
/// What we know about a key-item lock slot's key when routing a refusal ([`route_lock_refusal`]).
|
||||
enum KeyFact {
|
||||
/// The key is in our bags — the ref would open with it (the key-item ON_USE cast, deferred);
|
||||
/// never toast "Requires <key>" at a player holding the key.
|
||||
Held,
|
||||
/// Not held; the item template names it ("Requires Shadowforge Key").
|
||||
Named(String),
|
||||
/// Not held and the template isn't cached yet — the ref's `GetRecord` miss is silent (§8.8
|
||||
@@ -410,109 +394,90 @@ enum KeyFact {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Resolve a hovered GameObject's right-click action from its lock (decision 0239). Not-yet-queried
|
||||
/// or no `Lock.dbc` → treat as lockless (`Use`): the stream-in query makes "not cached" a rare race,
|
||||
/// and `Use` is both the correct lockless action and a harmless no-op on a chest whose template is
|
||||
/// still in flight. A lockable object matches the lock's **skill** slots against the player's
|
||||
/// **known** spells (an `OPEN_LOCK` whose `EffectMiscValue` is the slot's `LockType`) — "Opening"
|
||||
/// for keyless chests, Mining / Herb Gathering / Pick Lock for skill locks — then rank-gates the
|
||||
/// cast exactly as the ref's resolver does (`0x5f850f`): a known-but-under-rank opener refuses
|
||||
/// **client-side** (the "Requires Mining 100" toast), it never reaches the wire. An unresolvable
|
||||
/// rank (skill lines / self store not streamed yet) fails OPEN — cast and let the server re-check
|
||||
/// (vmangos answers `LOW_CASTLEVEL`), never a wrongly-refused click. The effective rank is
|
||||
/// value + both bonuses (vmangos `GetSkillValue`; the ref compare `0x6e3760`'s exact bonus
|
||||
/// handling is unpinned — INTERIM, decision 0545). Item-key locks aren't openable yet (the
|
||||
/// key-item ON_USE cast is deferred), but a missing key names itself ([`KeyFact`]).
|
||||
/// Resolve a hovered GameObject's right-click action from its lock — the reference's USE sender
|
||||
/// `0x5f33e0` over the shared resolver (decisions 0239 / 0545 / **0752**).
|
||||
///
|
||||
/// Not-yet-queried or no `Lock.dbc` → treat as lockless (`Use`): the stream-in query makes "not
|
||||
/// cached" a rare race, and `Use` is both the correct lockless action and a harmless no-op on a
|
||||
/// chest whose template is still in flight.
|
||||
///
|
||||
/// 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.
|
||||
fn resolve_go_action(
|
||||
guid: u64,
|
||||
inputs: &mut GoLockInputs,
|
||||
known: &std::collections::HashSet<u32>,
|
||||
go_store: Option<&ObjectStore>,
|
||||
go: Option<(&ObjectStore, u32)>,
|
||||
me_store: Option<&ObjectStore>,
|
||||
net: &NetCommands,
|
||||
) -> GoAction {
|
||||
let (spells, skill_lines, lock_types) = (
|
||||
inputs.spells.as_deref(),
|
||||
inputs.skill_lines.as_deref(),
|
||||
inputs.lock_types.as_deref(),
|
||||
);
|
||||
let items = &mut *inputs.items;
|
||||
let Some(tmpl) = inputs.templates.get(guid) else {
|
||||
return GoAction::Use;
|
||||
};
|
||||
let Some(locks) = inputs.locks.as_deref() else {
|
||||
return GoAction::Use;
|
||||
};
|
||||
// A lockId whose row is missing (or all-empty) is "no lock" — the ref resolver's `0x5f8180`
|
||||
// null → FALSE with spell 0 → `CMSG_GAMEOBJ_USE` (§8.4 C6).
|
||||
if !locks.0.is_locked(tmpl.lock_id) {
|
||||
return GoAction::Use;
|
||||
}
|
||||
let Some(slots) = locks.0.slots(tmpl.lock_id) else {
|
||||
// A lockId whose row is missing is "no lock" — the ref resolver's `0x5f8180` null → FALSE with
|
||||
// spell 0 → `CMSG_GAMEOBJ_USE` (§8.4 C6).
|
||||
let Some(slots) = locks.0.slots(tmpl.lock_id).filter(|_| tmpl.lock_id != 0) else {
|
||||
return GoAction::Use;
|
||||
};
|
||||
// The resolver scan (§8.4 `0x5f83d0`, §8.8's timing): remember the FIRST known matching
|
||||
// opener unconditionally — the ref writes the spell-id out-param at `0x5f84f8` BEFORE the
|
||||
// rank test, and that nonzero-ness is the "Requires Herbalism" (0xdf) vs "Requires Mining
|
||||
// 100" (0xe0) discriminator — then rank-gate the actual cast.
|
||||
let mut matched: Option<u32> = None;
|
||||
if let Some(spells) = spells {
|
||||
for slot in slots
|
||||
.iter()
|
||||
.filter(|s| s.key_type == benilla_formats::LOCK_KEY_SKILL)
|
||||
{
|
||||
let Some(&spell_id) = known.iter().find(|&&id| {
|
||||
spells.catalog.get(id).and_then(|d| d.open_lock_type) == Some(slot.index)
|
||||
}) else {
|
||||
continue;
|
||||
let facts = super::lock::go_facts(go);
|
||||
let mut matched = None;
|
||||
let outcome = super::lock::resolve_lock(
|
||||
slots,
|
||||
known,
|
||||
inputs.spells.as_deref(),
|
||||
me_store,
|
||||
&inputs.items,
|
||||
facts,
|
||||
&mut matched,
|
||||
);
|
||||
let key_entry = match outcome {
|
||||
super::lock::LockOutcome::Unlocked => return GoAction::Use,
|
||||
super::lock::LockOutcome::OpenBySpell(spell_id) => return GoAction::OpenLock(spell_id),
|
||||
super::lock::LockOutcome::OpenByKey(entry) => entry,
|
||||
super::lock::LockOutcome::Unmet => {
|
||||
// Unopenable — §8.8's routing, which keys off Lock.dbc **slot 0** regardless of which
|
||||
// slot the resolver walked.
|
||||
let slot0 = slots[0];
|
||||
let key = if slot0.key_type == benilla_formats::LOCK_KEY_ITEM {
|
||||
match inputs.items.template(slot0.index, 0, net) {
|
||||
Some(info) => KeyFact::Named(info.name.clone()),
|
||||
None => KeyFact::Unknown,
|
||||
}
|
||||
} else {
|
||||
KeyFact::Unknown
|
||||
};
|
||||
matched.get_or_insert(spell_id);
|
||||
let rank = skill_lines
|
||||
.and_then(|sl| sl.catalog.spell_to_line(spell_id))
|
||||
.and_then(|line| me_store.map(|s| effective_skill_rank(s, line)))
|
||||
.unwrap_or(u32::MAX);
|
||||
if rank >= slot.skill {
|
||||
return GoAction::OpenLock(spell_id);
|
||||
}
|
||||
let lock_types = inputs.lock_types.as_deref();
|
||||
return GoAction::Refuse(route_lock_refusal(
|
||||
&slot0,
|
||||
matched.is_some(),
|
||||
facts.flag_locked,
|
||||
go.map_or(-1, |(s, _)| s.0.gameobject_type_id()),
|
||||
facts.level,
|
||||
lock_types.and_then(|lt| lt.0.name(slot0.index)),
|
||||
key,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Unopenable — gather the facts the toast routing reads (§8.8 keys off Lock.dbc slot 0).
|
||||
let slot0 = slots[0];
|
||||
let key = if slot0.key_type == benilla_formats::LOCK_KEY_ITEM {
|
||||
if me_store.is_some_and(|s| crate::ui_items::count_of(&s.0, items, slot0.index) > 0) {
|
||||
KeyFact::Held
|
||||
} else if let Some(info) = items.template(slot0.index, 0, net) {
|
||||
KeyFact::Named(info.name.clone())
|
||||
} else {
|
||||
KeyFact::Unknown
|
||||
}
|
||||
} else {
|
||||
KeyFact::Unknown
|
||||
};
|
||||
GoAction::Refuse(route_lock_refusal(
|
||||
&slot0,
|
||||
matched.is_some(),
|
||||
go_store.is_some_and(|s| s.0.gameobject_flags() & 0x2 != 0),
|
||||
go_store.map_or(-1, |s| s.0.gameobject_type_id()),
|
||||
go_store.map_or(0, |s| s.0.gameobject_level()),
|
||||
lock_types.and_then(|lt| lt.0.name(slot0.index)),
|
||||
key,
|
||||
))
|
||||
}
|
||||
|
||||
/// The player's effective rank on a skill line — descriptor value + both bonuses, floored at 0
|
||||
/// (vmangos `Player::GetSkillValue`'s sum; the ref's `0x6e3760` bonus handling is unpinned —
|
||||
/// INTERIM, decision 0545). `0` when the line isn't in the skill block.
|
||||
fn effective_skill_rank(store: &ObjectStore, line: u32) -> u32 {
|
||||
for i in 0..benilla_protocol::messages::PLAYER_SKILL_SLOTS {
|
||||
if let Some(s) = store.0.player_skill(i) {
|
||||
if u32::from(s.skill_id) == line {
|
||||
let v = i32::from(s.value) + i32::from(s.temp_bonus) + i32::from(s.perm_bonus);
|
||||
return v.max(0) as u32;
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
match inputs
|
||||
.items
|
||||
.template(key_entry, 0, net)
|
||||
.and_then(|i| i.use_spell.as_ref().map(|s| s.spell_id))
|
||||
{
|
||||
Some(spell_id) => GoAction::OpenLock(spell_id),
|
||||
None => GoAction::Refuse(None),
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// The client-local toast for an unopenable lock — the ref's routing, transcribed (wow-re
|
||||
@@ -548,7 +513,7 @@ fn route_lock_refusal(
|
||||
}
|
||||
match slot0.key_type {
|
||||
benilla_formats::LOCK_KEY_ITEM => match key {
|
||||
KeyFact::Held | KeyFact::Unknown => None,
|
||||
KeyFact::Unknown => None,
|
||||
KeyFact::Named(name) => Some(UiError {
|
||||
key: "ERR_USE_LOCKED_WITH_ITEM_S",
|
||||
fill_s: Some(name),
|
||||
@@ -558,11 +523,7 @@ fn route_lock_refusal(
|
||||
benilla_formats::LOCK_KEY_SKILL => {
|
||||
let name = lock_type_name.unwrap_or("UNKNOWN").to_string();
|
||||
if opener_known {
|
||||
let required = if slot0.skill != 0 {
|
||||
slot0.skill
|
||||
} else {
|
||||
go_level * 5
|
||||
};
|
||||
let required = super::lock::required_skill(slot0, go_level).max(0) as u32;
|
||||
Some(UiError {
|
||||
key: "ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
|
||||
fill_s: Some(name),
|
||||
@@ -729,6 +690,7 @@ mod tests {
|
||||
key_type: LOCK_KEY_SKILL,
|
||||
index,
|
||||
skill,
|
||||
action: 0,
|
||||
};
|
||||
// Herb, Herbalism unknown → 0xdf "Requires %s" filled with the LockType name.
|
||||
let e = route_lock_refusal(
|
||||
@@ -789,11 +751,13 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(e.fill_s.as_deref(), Some("UNKNOWN"));
|
||||
// Key lock, key absent + named → 0xde "Requires %s" with the item name; the template
|
||||
// miss and the key-in-hand (deferred open) cases are silent, like the ref.
|
||||
// miss is silent, like the ref (a key we DO hold never reaches the toast at all — the
|
||||
// resolver returns `OpenByKey` and the click casts it, decision 0752).
|
||||
let key_slot = LockSlot {
|
||||
key_type: LOCK_KEY_ITEM,
|
||||
index: 11000,
|
||||
skill: 0,
|
||||
action: 1,
|
||||
};
|
||||
let e = route_lock_refusal(
|
||||
&key_slot,
|
||||
@@ -809,7 +773,6 @@ mod tests {
|
||||
(e.key, e.fill_s.as_deref()),
|
||||
("ERR_USE_LOCKED_WITH_ITEM_S", Some("Shadowforge Key"))
|
||||
);
|
||||
assert!(route_lock_refusal(&key_slot, false, false, 0, 0, None, KeyFact::Held).is_none());
|
||||
assert!(
|
||||
route_lock_refusal(&key_slot, false, false, 0, 0, None, KeyFact::Unknown).is_none()
|
||||
);
|
||||
@@ -838,6 +801,7 @@ mod tests {
|
||||
key_type: 7,
|
||||
index: 0,
|
||||
skill: 0,
|
||||
action: 0,
|
||||
};
|
||||
let e = route_lock_refusal(&odd, false, false, 3, 0, None, KeyFact::Unknown).unwrap();
|
||||
assert_eq!(e.key, "ERR_USE_CANT_OPEN");
|
||||
|
||||
@@ -365,14 +365,22 @@ pub(super) fn classify_cursor(
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
mut cursor: ResMut<WorldCursor>,
|
||||
units: Query<(&Transform, Option<&ObjectStore>, Option<&NetEntity>)>,
|
||||
units: Query<(
|
||||
&Transform,
|
||||
Option<&ObjectStore>,
|
||||
Option<&NetEntity>,
|
||||
Option<&crate::go_anim::GoAnim>,
|
||||
)>,
|
||||
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
|
||||
// The GameObject cursor is data-driven (decision 0236, wow-re cursor-system §4): the ask-once
|
||||
// template (its `lockId`) + Lock.dbc + LockType.dbc name the cursor. All absent without client
|
||||
// data or before a hovered GO's template answers — a lock-bearing GO then reads as the gear.
|
||||
go_templates: Res<crate::go_templates::GameObjectTemplates>,
|
||||
locks: Option<Res<crate::go_templates::Locks>>,
|
||||
lock_types: Option<Res<crate::go_templates::LockTypes>>,
|
||||
// The same param carries the lock chain `usable` consults (decision 0752) — one
|
||||
// [`super::lock::GoLockInputs`], so the cursor and the click ask literally the same question.
|
||||
go_inputs: super::lock::GoLockInputs,
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
// `[0xb700e4]`/`[0xb700e8]` — the skin leg's learned-ability precondition (decision 0752).
|
||||
learned: Res<crate::ui_action::LearnedAbilities>,
|
||||
// The QUESTGIVER leg's gate reads the per-guid `SMSG_QUESTGIVER_STATUS` store — see
|
||||
// [`questgiver_has_quest`].
|
||||
quest: Res<crate::ui_quest::QuestGiver>,
|
||||
@@ -386,27 +394,53 @@ pub(super) fn classify_cursor(
|
||||
// grayed twin is still the interim distance gate (decision 0243); the client's fuller `usable`
|
||||
// (lock satisfaction, player-state) is later.
|
||||
let resolve_go = || {
|
||||
let (go_tf, store, _) = units.get(hovered_object.target?).ok()?;
|
||||
let (go_tf, store, _, anim) = units.get(hovered_object.target?).ok()?;
|
||||
let store = store?;
|
||||
let (self_tf, _) = self_q.single().ok()?;
|
||||
let (self_tf, self_store) = self_q.single().ok()?;
|
||||
if !go_highlightable(store) {
|
||||
return None;
|
||||
}
|
||||
// The type's own cursor wins; a base type reads its lock's data-named cursor (needs the
|
||||
// ask-once template — a not-yet-answered GO falls back to the gear until it arrives).
|
||||
let lock_cursor = hovered_object
|
||||
.guid
|
||||
.and_then(|g| go_templates.get(g))
|
||||
.and_then(|t| go_lock_cursor(t.lock_id, locks.as_deref(), lock_types.as_deref()));
|
||||
let tmpl = hovered_object.guid.and_then(|g| go_inputs.templates.get(g));
|
||||
let lock_id = tmpl.map_or(0, |t| t.lock_id);
|
||||
let lock_cursor = go_lock_cursor(
|
||||
lock_id,
|
||||
go_inputs.locks.as_deref(),
|
||||
go_inputs.lock_types.as_deref(),
|
||||
);
|
||||
let kind = go_cursor_kind(store.0.gameobject_type_id(), lock_cursor);
|
||||
let dist_sq = go_tf.translation.distance_squared(self_tf.translation);
|
||||
let unable = kind != CursorKind::PickLock && dist_sq > GO_INTERACT_RANGE_SQ;
|
||||
// `usable 0x5f3130`, the two arms we model — the LOCK arm first, as the binary orders
|
||||
// them (`0x5f32a6` before the range check at `0x5f330c`), then the interim range gray.
|
||||
// The lock arm runs **only when `GO_FLAG_LOCKED` is set** (§8.8): that is why an
|
||||
// ungatherable herb node keeps its lit GatherHerbs cursor and only refuses on the click,
|
||||
// while a padlocked door grays. `PickLock` never grays (§4: `LockType.Id == 1` skips the
|
||||
// usable gate), so the rogue's affordance still reads as available.
|
||||
let facts = super::lock::go_facts(Some((store, crate::go_anim::go_state(anim, store))));
|
||||
let lock_unmet = go_inputs
|
||||
.locks
|
||||
.as_deref()
|
||||
.and_then(|l| l.0.slots(lock_id).filter(|_| lock_id != 0))
|
||||
.is_some_and(|slots| {
|
||||
super::lock::resolve_lock(
|
||||
slots,
|
||||
&player_actions.spells,
|
||||
go_inputs.spells.as_deref(),
|
||||
Some(self_store),
|
||||
&go_inputs.items,
|
||||
facts,
|
||||
&mut None,
|
||||
)
|
||||
.blocks_usable(facts.flag_locked)
|
||||
});
|
||||
let unable = kind != CursorKind::PickLock && (lock_unmet || dist_sq > GO_INTERACT_RANGE_SQ);
|
||||
Some((kind, unable))
|
||||
};
|
||||
// The reference makes one pick over all CGObjects and switches on type; benilla picks unit and
|
||||
// GameObject separately, then classifies whichever is nearer under the cursor.
|
||||
let resolve_unit = || {
|
||||
let (unit_tf, store, net) = units.get(hovered.target?).ok()?;
|
||||
let (unit_tf, store, net, _) = units.get(hovered.target?).ok()?;
|
||||
let store = store?;
|
||||
let (self_tf, self_store) = self_q.single().ok()?;
|
||||
let dist_sq = unit_tf.translation.distance_squared(self_tf.translation);
|
||||
@@ -425,7 +459,12 @@ pub(super) fn classify_cursor(
|
||||
// normal mob (director-measured, confirmed).
|
||||
return Some((CursorKind::Pickup, !in_melee));
|
||||
}
|
||||
if store.0.unit_flags() & UNIT_FLAG_SKINNABLE != 0 {
|
||||
// The skin leg's SECOND precondition, byte-verified: the flag alone is not enough —
|
||||
// the reference also requires the learn-time latch `[0xb700e4 + 4×isPlayerTarget]` to
|
||||
// be non-null (wow-re cursor-system.md §3, `0x482589`), i.e. **the player must have
|
||||
// learned a Skinning spell**. A non-skinner gets no knife on a skinnable corpse; the
|
||||
// corpse falls through to Point like any other unlootable one (decision 0752).
|
||||
if store.0.unit_flags() & UNIT_FLAG_SKINNABLE != 0 && learned.skinning.is_some() {
|
||||
return Some((CursorKind::Skin, !in_melee));
|
||||
}
|
||||
return None; // a plain corpse: Point
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! The GameObject **lock chain** — the client's resolver `0x5f83d0`, its per-slot **Action** gate
|
||||
//! (`0x5f81d0`), and the §8.8 refusal-toast routing (decisions 0239 / 0545 / **0752**).
|
||||
//!
|
||||
//! **One chain, two consumers — exactly as the reference.** `CGGameObject`'s per-type strategy
|
||||
//! calls the same resolver twice: from **`usable` `0x5f3130`** (which decides the cursor's grayed
|
||||
//! twin *and* whether the right-click is sent at all — §4a/§8.7), and from the **USE sender
|
||||
//! `0x5f33e0`** (which picks between `CMSG_GAMEOBJ_USE`, an `OPEN_LOCK` cast, and a client-local
|
||||
//! toast — §8.4). That is why it lives here rather than inside either caller: the icon and the
|
||||
//! click agree by construction only if they ask the same question.
|
||||
//!
|
||||
//! ## The Action gate — the piece that was missing (0752)
|
||||
//!
|
||||
//! Before the resolver will *consider* a `Lock.dbc` slot it asks `0x5f81d0(GO, Action[i])`, which
|
||||
//! answers from the GameObject's own **state** and its `GO_FLAG_LOCKED` wire bit — see
|
||||
//! [`benilla_formats::LockSlot::available`]. Without it, the ladder is over-permissive in a way
|
||||
//! that looks arbitrary from the chair: nearly every keyed door in 5875 carries a spare
|
||||
//! `Quick Open` slot (`LockType 10`, `Skill 0`, **`Action 0`**), *every* character knows spell 6247
|
||||
//! "Opening", and Action 0 means "only when the object is NOT flagged locked" — so skipping the
|
||||
//! gate hands every padlocked door in the game to every player. The Searing Gorge gate (lock 84)
|
||||
//! was the one that refused because it is the one that carries no `Action 0` slot.
|
||||
//!
|
||||
//! ## Satisfaction is the SPELL's value, not the player's skill
|
||||
//!
|
||||
//! `0x5f850f` compares the matched spell's own OPEN_LOCK **effect value**
|
||||
//! ([`benilla_formats::SpellDisplay::open_lock_skill`]) against the slot's requirement — which is
|
||||
//! `Skill[i]`, or **`GAMEOBJECT_LEVEL × 5`** when `Skill[i]` is zero (`0x5f84be`). It never reads
|
||||
//! the skill block. That is not an approximation on the client's part: the DBC encodes the same
|
||||
//! number (Pick Lock at 60 → 300, a rogue's cap; Mining at 60 → 300).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use benilla_formats::{LockSlot, LOCK_KEY_ITEM, LOCK_KEY_SKILL};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::ObjectStore;
|
||||
|
||||
/// The lock chain's full data set as ONE [`SystemParam`] (decisions 0239 / 0545 / 0752): the
|
||||
/// ask-once GO-template cache, `Lock.dbc` + `LockType.dbc`, the spell catalog, and the ask-once
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// The client's stored `GAMEOBJECT_STATE` (`go+0x27c`) — [`crate::go_anim::go_state`].
|
||||
pub(super) state: u32,
|
||||
/// `GAMEOBJECT_FLAGS & GO_FLAG_LOCKED (0x2)`.
|
||||
pub(super) 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,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// No `Lock.dbc` row, or every slot empty — `0x5f8180` null / `[ebp-1]` never set. The object
|
||||
/// opens by `CMSG_GAMEOBJ_USE`.
|
||||
Unlocked,
|
||||
/// A skill slot the player satisfies with a known `OPEN_LOCK` spell — cast it at the GO.
|
||||
OpenBySpell(u32),
|
||||
/// A key slot whose item the player carries; the value is the **key's entry**. The reference
|
||||
/// resolves the item's ON_USE spell here (`0x5d8c80`) and casts that.
|
||||
OpenByKey(u32),
|
||||
/// A lock is present and no slot is satisfied — the client-local refusal, **no packet**.
|
||||
Unmet,
|
||||
}
|
||||
|
||||
impl LockOutcome {
|
||||
/// The `usable` half: `0x5f3130`'s lock arm returns *not usable* only for [`Self::Unmet`], and
|
||||
/// **only when `GO_FLAG_LOCKED` is set** (`0x5f32a6`: `shr 1; test al,1; je`). With the flag
|
||||
/// clear the arm is skipped entirely — which is exactly why a herb node you cannot gather still
|
||||
/// shows the lit `GatherHerbs` cursor and only toasts on the click.
|
||||
pub(super) fn blocks_usable(self, flag_locked: bool) -> bool {
|
||||
flag_locked && self == LockOutcome::Unmet
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
match go {
|
||||
Some((store, state)) => GoFacts {
|
||||
state,
|
||||
flag_locked: store.0.gameobject_flags() & GO_FLAG_LOCKED != 0,
|
||||
level: store.0.gameobject_level(),
|
||||
},
|
||||
None => GoFacts {
|
||||
state: benilla_formats::GO_STATE_ACTIVE,
|
||||
flag_locked: false,
|
||||
level: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `GO_FLAG_LOCKED` (vmangos `GameObjectFlags`) — the wire bit that both selects the Action-1
|
||||
/// ("unlock") slots and arms `usable`'s lock check (§8.8, `0x5f32a6`).
|
||||
pub(crate) const GO_FLAG_LOCKED: u32 = 0x2;
|
||||
|
||||
/// The client's lock resolver **`0x5f83d0`**, transcribed (decision 0752).
|
||||
///
|
||||
/// Walks the 8 `Lock.dbc` slots **in order**, dispatching each by `Type`:
|
||||
/// - **SKILL (2)** — gate on [`LockSlot::available`], then linear-scan the player's known spells
|
||||
/// for one whose `SPELL_EFFECT_OPEN_LOCK` `EffectMiscValue` equals the slot's `Index`; the first
|
||||
/// such match sets `matched_spell` unconditionally (`0x5f84f8`, *before* the rank test — that
|
||||
/// nonzero-ness is §8.8's `0xdf`-vs-`0xe0` discriminator), then the spell's effect value is
|
||||
/// compared against the requirement (`0x5f850f`). Sufficient → satisfied.
|
||||
/// - **KEY (1)** — gate the same way, then look for the key item in our bags/keyring.
|
||||
/// - **NONE (0)** — skipped without marking the lock real.
|
||||
///
|
||||
/// Any SKILL/KEY slot marks the lock **real** even when its Action gate rejects it (the binary
|
||||
/// writes `[ebp-1] = 1` *before* calling `0x5f81d0`), so a door whose only opener is gated out
|
||||
/// refuses rather than falling through to `CMSG_GAMEOBJ_USE`.
|
||||
///
|
||||
/// `matched_spell` is the out-param the toast routing needs; it is written for a LockType match
|
||||
/// even when the value test then fails.
|
||||
///
|
||||
/// One deliberate difference: the reference walks its known-spell **array** in index order, while
|
||||
/// `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(
|
||||
slots: &[LockSlot],
|
||||
known: &HashSet<u32>,
|
||||
spells: Option<&crate::ui_action::Spells>,
|
||||
me: Option<&ObjectStore>,
|
||||
items: &crate::items::Items,
|
||||
go: GoFacts,
|
||||
matched_spell: &mut Option<u32>,
|
||||
) -> LockOutcome {
|
||||
let caster_level = me.and_then(|s| s.0.unit_level()).unwrap_or(0);
|
||||
let mut real = false;
|
||||
for slot in slots {
|
||||
match slot.key_type {
|
||||
LOCK_KEY_SKILL => {
|
||||
real = true;
|
||||
if !slot.available(go.state, go.flag_locked) {
|
||||
continue;
|
||||
}
|
||||
let Some(spells) = spells else { continue };
|
||||
for &id in known {
|
||||
let Some(spell) = spells.catalog.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if spell.open_lock_type() != Some(slot.index) {
|
||||
continue;
|
||||
}
|
||||
// `0x5f84f8` — the out-param is written on the LockType match, before the
|
||||
// value test.
|
||||
matched_spell.get_or_insert(id);
|
||||
let provides = spell.open_lock_skill(caster_level).unwrap_or(0);
|
||||
if provides >= required_skill(slot, go.level) {
|
||||
return LockOutcome::OpenBySpell(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
LOCK_KEY_ITEM => {
|
||||
real = true;
|
||||
if !slot.available(go.state, go.flag_locked) {
|
||||
continue;
|
||||
}
|
||||
if me.is_some_and(|s| holds_item(&s.0, items, slot.index)) {
|
||||
return LockOutcome::OpenByKey(slot.index);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if real {
|
||||
LockOutcome::Unmet
|
||||
} else {
|
||||
LockOutcome::Unlocked
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot's required skill: `Skill[i]`, or **`GAMEOBJECT_LEVEL × 5`** when it is zero
|
||||
/// (`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 {
|
||||
if slot.skill != 0 {
|
||||
slot.skill as i32
|
||||
} else {
|
||||
(go_level * 5) as 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.
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_formats::{GO_STATE_ACTIVE, GO_STATE_READY, LOCK_KEY_NONE};
|
||||
|
||||
fn skill_slot(index: u32, skill: u32, action: u32) -> LockSlot {
|
||||
LockSlot {
|
||||
key_type: LOCK_KEY_SKILL,
|
||||
index,
|
||||
skill,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
/// The requirement fallback (`0x5f84be`): a zero `Skill[i]` means GO-level × 5, not "free".
|
||||
#[test]
|
||||
fn zero_skill_falls_back_to_go_level_times_five() {
|
||||
assert_eq!(required_skill(&skill_slot(3, 0, 0), 0), 0);
|
||||
assert_eq!(required_skill(&skill_slot(3, 0, 0), 20), 100);
|
||||
// A nonzero Skill wins outright — the level is never consulted.
|
||||
assert_eq!(required_skill(&skill_slot(1, 280, 1), 60), 280);
|
||||
}
|
||||
|
||||
/// A door whose only opener is gated out by its Action must refuse, **not** fall through to
|
||||
/// `CMSG_GAMEOBJ_USE`: the binary marks the lock real (`[ebp-1] = 1`) *before* asking
|
||||
/// `0x5f81d0`. This is the difference between "locked door refuses" and "locked door opens".
|
||||
#[test]
|
||||
fn a_gated_out_slot_still_makes_the_lock_real() {
|
||||
let slots = [
|
||||
skill_slot(10, 0, 0), // Quick Open — Action 0, gated out on a flagged-locked door
|
||||
LockSlot::default(),
|
||||
];
|
||||
let items = crate::items::Items::default();
|
||||
let mut matched = None;
|
||||
let out = resolve_lock(
|
||||
&slots,
|
||||
&HashSet::new(),
|
||||
None,
|
||||
None,
|
||||
&items,
|
||||
GoFacts {
|
||||
state: GO_STATE_READY,
|
||||
flag_locked: true,
|
||||
level: 0,
|
||||
},
|
||||
&mut matched,
|
||||
);
|
||||
assert_eq!(out, LockOutcome::Unmet);
|
||||
assert!(
|
||||
out.blocks_usable(true),
|
||||
"a flagged-locked unmet lock grays the cursor"
|
||||
);
|
||||
// …and with the flag clear the SAME lock is not a `usable` blocker (the arm is skipped) —
|
||||
// the herb-node case: lit cursor, toast on click.
|
||||
assert!(!out.blocks_usable(false));
|
||||
}
|
||||
|
||||
/// The reported bug, end to end, on the **real** shipped `Lock.dbc` / `Spell.dbc` values
|
||||
/// (decision 0752). `benilla-formats`' own `real_lock_catalog_reads_the_action_column` and
|
||||
/// `real_spell_catalog_computes_the_lock_skill_an_opener_provides` pin these numbers against
|
||||
/// the files, so this stays a pure unit test while still describing real data.
|
||||
///
|
||||
/// Before the Action gate, every one of these doors opened for every character: "Opening"
|
||||
/// (6247) satisfied their spare `Quick Open` slot with a flat 100 ≥ 0.
|
||||
#[test]
|
||||
fn a_keyed_door_refuses_the_universally_known_opening_spell() {
|
||||
use benilla_formats::{OpenLock, SpellCatalog, SpellDisplay};
|
||||
|
||||
// Scholomance Door, lockId 1159 — key 13704 / Pick Lock 280 / Quick Open / Quick Close /
|
||||
// Blasting 300; the template ships GO_FLAG_LOCKED.
|
||||
let mut scholomance = [LockSlot::default(); 8];
|
||||
scholomance[0] = LockSlot {
|
||||
key_type: LOCK_KEY_ITEM,
|
||||
index: 13704,
|
||||
skill: 0,
|
||||
action: 1,
|
||||
};
|
||||
scholomance[1] = skill_slot(1, 280, 1);
|
||||
scholomance[2] = skill_slot(10, 0, 0);
|
||||
scholomance[3] = skill_slot(11, 0, 2);
|
||||
scholomance[4] = skill_slot(16, 300, 1);
|
||||
|
||||
// Two openers with their real value inputs: the "Opening" every character is created
|
||||
// with, and Pick Lock (whose value tracks 5×level).
|
||||
let opening = SpellDisplay {
|
||||
open_lock: Some(OpenLock {
|
||||
lock_type: 10,
|
||||
effect: 0,
|
||||
}),
|
||||
effect_base_points: [99, 0, 0],
|
||||
effect_base_dice: [1, 0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
let pick_lock = SpellDisplay {
|
||||
open_lock: Some(OpenLock {
|
||||
lock_type: 1,
|
||||
effect: 0,
|
||||
}),
|
||||
effect_base_points: [4, 0, 0],
|
||||
effect_base_dice: [1, 0, 0],
|
||||
effect_real_points_per_level: [5.0, 0.0, 0.0],
|
||||
spell_level: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let spells = crate::ui_action::Spells {
|
||||
catalog: SpellCatalog::from_displays(
|
||||
[(6247, opening), (1804, pick_lock)].into_iter().collect(),
|
||||
),
|
||||
..crate::ui_action::Spells::empty_for_tests()
|
||||
};
|
||||
let items = crate::items::Items::default();
|
||||
let locked_shut = GoFacts {
|
||||
state: GO_STATE_READY,
|
||||
flag_locked: true,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// A character who knows only "Opening" — i.e. everybody — is refused.
|
||||
let mut matched = None;
|
||||
assert_eq!(
|
||||
resolve_lock(
|
||||
&scholomance,
|
||||
&HashSet::from([6247]),
|
||||
Some(&spells),
|
||||
None,
|
||||
&items,
|
||||
locked_shut,
|
||||
&mut matched,
|
||||
),
|
||||
LockOutcome::Unmet,
|
||||
);
|
||||
assert_eq!(
|
||||
matched, None,
|
||||
"a gated-out slot never even reaches the known-spell scan"
|
||||
);
|
||||
|
||||
// Clear the flag and the SAME door opens to the SAME spell — proof that the gate is what
|
||||
// refuses, not the value test. (No shipped door does this; it isolates the mechanism.)
|
||||
assert_eq!(
|
||||
resolve_lock(
|
||||
&scholomance,
|
||||
&HashSet::from([6247]),
|
||||
Some(&spells),
|
||||
None,
|
||||
&items,
|
||||
GoFacts {
|
||||
flag_locked: false,
|
||||
..locked_shut
|
||||
},
|
||||
&mut None,
|
||||
),
|
||||
LockOutcome::OpenBySpell(6247),
|
||||
);
|
||||
|
||||
// Pick Lock sits on an Action 1 slot, so the flag *selects* it — but with no self store
|
||||
// the level reads 0, Pick Lock provides 5, and 5 < 280 refuses. The out-param is still
|
||||
// written, which is §8.8's `0xdf`-vs-`0xe0` discriminator.
|
||||
let mut matched = None;
|
||||
assert_eq!(
|
||||
resolve_lock(
|
||||
&scholomance,
|
||||
&HashSet::from([1804]),
|
||||
Some(&spells),
|
||||
None,
|
||||
&items,
|
||||
locked_shut,
|
||||
&mut matched,
|
||||
),
|
||||
LockOutcome::Unmet,
|
||||
);
|
||||
assert_eq!(
|
||||
matched,
|
||||
Some(1804),
|
||||
"a LockType match writes the out-param before the value test"
|
||||
);
|
||||
|
||||
// The Searing Gorge gate (lockId 84) — the reporter's counter-example. No Action-0 slot at
|
||||
// all, which is why it refused even before the gate existed; it must still refuse.
|
||||
let mut searing_gorge = [LockSlot::default(); 8];
|
||||
searing_gorge[0] = LockSlot {
|
||||
key_type: LOCK_KEY_ITEM,
|
||||
index: 5396,
|
||||
skill: 0,
|
||||
action: 1,
|
||||
};
|
||||
searing_gorge[1] = skill_slot(1, 225, 1);
|
||||
assert_eq!(
|
||||
resolve_lock(
|
||||
&searing_gorge,
|
||||
&HashSet::from([6247]),
|
||||
Some(&spells),
|
||||
None,
|
||||
&items,
|
||||
locked_shut,
|
||||
&mut None,
|
||||
),
|
||||
LockOutcome::Unmet,
|
||||
);
|
||||
|
||||
// And the gate must not touch what already worked: a Copper Vein (lockId 38 — one Mining
|
||||
// slot, Skill 0, Action 0) on an unflagged, READY object still opens for a miner.
|
||||
let mining = SpellDisplay {
|
||||
open_lock: Some(OpenLock {
|
||||
lock_type: 3,
|
||||
effect: 0,
|
||||
}),
|
||||
effect_base_points: [-1, 0, 0],
|
||||
effect_base_dice: [1, 0, 0],
|
||||
effect_real_points_per_level: [5.0, 0.0, 0.0],
|
||||
..Default::default()
|
||||
};
|
||||
let with_mining = crate::ui_action::Spells {
|
||||
catalog: SpellCatalog::from_displays([(2575, mining)].into_iter().collect()),
|
||||
..crate::ui_action::Spells::empty_for_tests()
|
||||
};
|
||||
let mut vein = [LockSlot::default(); 8];
|
||||
vein[0] = skill_slot(3, 0, 0);
|
||||
assert_eq!(
|
||||
resolve_lock(
|
||||
&vein,
|
||||
&HashSet::from([2575]),
|
||||
Some(&with_mining),
|
||||
None,
|
||||
&items,
|
||||
GoFacts {
|
||||
state: GO_STATE_READY,
|
||||
flag_locked: false,
|
||||
level: 0
|
||||
},
|
||||
&mut None,
|
||||
),
|
||||
LockOutcome::OpenBySpell(2575),
|
||||
);
|
||||
}
|
||||
|
||||
/// An all-empty row is not a lock at all — `CMSG_GAMEOBJ_USE`.
|
||||
#[test]
|
||||
fn an_empty_row_is_unlocked() {
|
||||
let slots = [LockSlot::default(); 8];
|
||||
let items = crate::items::Items::default();
|
||||
let mut matched = None;
|
||||
assert_eq!(
|
||||
resolve_lock(
|
||||
&slots,
|
||||
&HashSet::new(),
|
||||
None,
|
||||
None,
|
||||
&items,
|
||||
GoFacts {
|
||||
state: GO_STATE_ACTIVE,
|
||||
flag_locked: false,
|
||||
level: 0
|
||||
},
|
||||
&mut matched,
|
||||
),
|
||||
LockOutcome::Unlocked
|
||||
);
|
||||
assert_eq!(slots[0].key_type, LOCK_KEY_NONE);
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,13 @@ mod cursor_mode;
|
||||
mod flash;
|
||||
mod highlight;
|
||||
mod hover;
|
||||
mod lock;
|
||||
mod ring;
|
||||
mod scan;
|
||||
|
||||
pub(crate) use cursor_mode::{CursorKind, WorldCursor, 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
|
||||
// nameplate colour gate (`crate::nameplates`), the flash's only two consumers (byte-verified).
|
||||
pub(crate) use flash::CombatFlash;
|
||||
|
||||
@@ -134,11 +134,70 @@ impl Spells {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's **learned-ability latches** — `[0xb700e4]` and `[0xb700e8]`, mirrored (decision
|
||||
/// 0752). The reference does not scan the spell book to answer "can this player skin?": it caches
|
||||
/// the answer at *learn* time. `0x4b25e0`, right after setting the known-spell bit, tests the
|
||||
/// freshly-learned spell's `Effect[0]` and stores the spell id into a dedicated global —
|
||||
/// `0x5f` (`SPELL_EFFECT_SKINNING`) → `[0xb700e4]`, `0x74` (`SPELL_EFFECT_SKIN_PLAYER_CORPSE`) →
|
||||
/// `[0xb700e8]` — and the unlearn path `0x4b2c50` zeroes whichever global named that spell.
|
||||
///
|
||||
/// The world cursor's skin leg then reads `[0xb700e4 + 4×isPlayerTarget]` as a hard precondition
|
||||
/// (wow-re `cursor-system.md` §3, the skin/insignia row): **a corpse flagged `UNIT_FLAG_SKINNABLE`
|
||||
/// shows no skin cursor at all to a player who never learned Skinning.** Without it the ladder
|
||||
/// offers the knife to everyone, which is what the channel reported.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LearnedAbilities {
|
||||
/// `[0xb700e4]` — our known `SPELL_EFFECT_SKINNING` spell (creature skinning), `None` if we
|
||||
/// never learned one. It is also the spell the skin click casts, so there is one lookup here,
|
||||
/// not a second scan at the click.
|
||||
pub(crate) skinning: Option<u32>,
|
||||
/// `[0xb700e8]` — our known `SPELL_EFFECT_SKIN_PLAYER_CORPSE` spell (the PvP insignia). Kept
|
||||
/// for symmetry with the reference's pair; the insignia arm of the cursor isn't modelled yet.
|
||||
pub(crate) skin_player_corpse: Option<u32>,
|
||||
}
|
||||
|
||||
/// `SpellEffects` value `0x74` — `SPELL_EFFECT_SKIN_PLAYER_CORPSE` (the "Remove Insignia" family),
|
||||
/// the second of the two effects `0x4b25e0` latches.
|
||||
const SPELL_EFFECT_SKIN_PLAYER_CORPSE: u32 = 0x74;
|
||||
|
||||
/// Re-derive [`LearnedAbilities`] whenever the spell book changes — our stand-in for the
|
||||
/// reference's learn/unlearn write sites. Change-detected, so it is a no-op on almost every frame;
|
||||
/// rescanning the book beats threading a hook through every spell-arrival path, and it cannot
|
||||
/// drift from the book the way an incrementally-maintained latch could.
|
||||
fn track_learned_abilities(
|
||||
actions: Res<PlayerActions>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut learned: ResMut<LearnedAbilities>,
|
||||
) {
|
||||
let Some(spells) = spells else { return };
|
||||
if !actions.is_changed() && !spells.is_changed() {
|
||||
return;
|
||||
}
|
||||
let first_with = |effect: u32| {
|
||||
actions
|
||||
.spells
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&id| spells.catalog.get(id).is_some_and(|d| d.effect_1 == effect))
|
||||
};
|
||||
let (skinning, skin_player_corpse) = (
|
||||
first_with(benilla_formats::SPELL_EFFECT_SKINNING),
|
||||
first_with(SPELL_EFFECT_SKIN_PLAYER_CORPSE),
|
||||
);
|
||||
if (skinning, skin_player_corpse) != (learned.skinning, learned.skin_player_corpse) {
|
||||
*learned = LearnedAbilities {
|
||||
skinning,
|
||||
skin_player_corpse,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiActionPlugin;
|
||||
|
||||
impl Plugin for UiActionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<PlayerActions>()
|
||||
.init_resource::<LearnedAbilities>()
|
||||
.init_resource::<CastErrors>()
|
||||
.init_resource::<MountErrors>()
|
||||
.init_resource::<UiErrorKeys>()
|
||||
@@ -162,6 +221,12 @@ impl Plugin for UiActionPlugin {
|
||||
.before(UiInput),
|
||||
drain::drain_action_sets.after(UiInput),
|
||||
drain::drain_action_uses.after(UiInput),
|
||||
// The learned-ability latches must be current before the target chain's
|
||||
// cursor classifier reads them; the book feed runs in `UnitFeed`, so sitting
|
||||
// right after it is enough.
|
||||
track_learned_abilities
|
||||
.in_set(UnitFeed)
|
||||
.after(feed::feed_actions),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,16 @@
|
||||
//! `0x52aa20` law. The standalone-corpse builder joins when corpse objects stream.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::{UiScript, UnitState};
|
||||
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};
|
||||
use crate::target::{
|
||||
go_is_nearest, ring_reaction, Factions, Hovered, HoveredObject, GO_FLAG_LOCKED,
|
||||
};
|
||||
use crate::ui_action::{PlayerActions, Spells};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::{enrich_unit, snapshot, UnitFeed};
|
||||
@@ -504,8 +505,9 @@ fn drive_mouseover_tooltip(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
hovered: Res<Hovered>,
|
||||
hovered_go: Res<HoveredObject>,
|
||||
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>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -562,17 +564,8 @@ fn drive_mouseover_tooltip(
|
||||
return;
|
||||
}
|
||||
if let Some((entity, guid)) = go {
|
||||
// The GO plate rides the cursor (the reference's signpost hover — engine-seated at
|
||||
// the pointer, following it; 0281's corner law stays the UNIT flow's).
|
||||
let cursor_ui = window.iter().next().and_then(|w| {
|
||||
w.cursor_position()
|
||||
.map(|c| Vec2::new(c.x, w.height() - c.y))
|
||||
});
|
||||
if *last == LastHover::Go(guid) {
|
||||
if let Some(p) = cursor_ui {
|
||||
script.world_tooltip_move(p.x, p.y);
|
||||
}
|
||||
return;
|
||||
return; // corner-seated: a pointer moving over the same object re-renders nothing
|
||||
}
|
||||
let Some(template) = go_templates.get(guid).cloned() else {
|
||||
// Template in flight: ask once and retry next frame (`last` stays, so the show
|
||||
@@ -580,41 +573,60 @@ fn drive_mouseover_tooltip(
|
||||
go_templates.request(guid, &commands);
|
||||
return;
|
||||
};
|
||||
// The red requirement lines — flag-locked (GAMEOBJECT_FLAGS bit 0x2, vmangos
|
||||
// GO_FLAG_LOCKED) resolves the Lock.dbc slots: item keys name the key item (ask-once
|
||||
// through the template cache), skill locks name the profession.
|
||||
let mut requirements = Vec::new();
|
||||
let locked = stores
|
||||
.get(entity)
|
||||
.map(|s| s.0.gameobject_flags() & 0x2 != 0)
|
||||
.unwrap_or(false);
|
||||
if locked && template.lock_id != 0 {
|
||||
if let Some(slots) = locks.as_ref().and_then(|l| l.0.slots(template.lock_id)) {
|
||||
for slot in slots {
|
||||
match slot.key_type {
|
||||
benilla_formats::LOCK_KEY_ITEM => {
|
||||
if let Some(t) = items.template(slot.index, 0, &commands) {
|
||||
requirements.push(format!("Requires {}", t.name));
|
||||
}
|
||||
}
|
||||
benilla_formats::LOCK_KEY_SKILL => {
|
||||
if let Some(word) = lock_type_word(slot.index) {
|
||||
if slot.skill > 0 {
|
||||
requirements.push(format!("Requires {word} ({})", slot.skill));
|
||||
} else {
|
||||
requirements.push(format!("Requires {word}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
// The lock lines, transcribed from the builder `0x52aa20` (decision 0756). Two blocks, in
|
||||
// the binary's order, and both are narrower than the sweep we used to print:
|
||||
//
|
||||
// A) **"Locked"** — emitted iff `GAMEOBJECT_FLAGS & GO_FLAG_LOCKED` (`0x52aae5`:
|
||||
// `shr 1; test dl,1`). That flag gates this line and nothing else.
|
||||
// B) **ONE requirement line, from Lock.dbc SLOT 0 ONLY** (`[lockRow+4]` / `[lockRow+0x24]`
|
||||
// — the builder never walks the other seven), and only when slot 0 passes the same
|
||||
// per-slot Action gate the resolver uses (`0x52ab7e` → `0x5f81d0`, decision 0752):
|
||||
// · KEY → **white** `LOCKED_WITH_ITEM` "Requires <item>" (`0x854988`; `0x52acd9`
|
||||
// pushes `0xc0cf60` = white)
|
||||
// · SKILL, opener unknown **and** the object is flag-locked → **nothing at all**
|
||||
// (`0x52abf7: jne done`) — a padlocked door says "Locked" and names its key, never
|
||||
// its lockpicking rank
|
||||
// · SKILL, opener unknown, not flagged → **red** "Requires <word>" (the herb node)
|
||||
//
|
||||
// That is why the reference shows *"Locked" + "Requires Key to Searing Gorge"* on the
|
||||
// Searing Gorge door and not the `Requires Lockpicking (225)` line we used to add: the
|
||||
// Pick Lock requirement is that lock's slot **1**, and the builder never looks past 0.
|
||||
let go_store = stores.get(entity).ok();
|
||||
let flags = go_store.map_or(0, |s| s.0.gameobject_flags());
|
||||
let flag_locked = flags & GO_FLAG_LOCKED != 0;
|
||||
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
|
||||
.as_ref()
|
||||
.filter(|_| template.lock_id != 0)
|
||||
.and_then(|l| l.0.slots(template.lock_id))
|
||||
.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) {
|
||||
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.
|
||||
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
|
||||
if let Some(word) = lock_type_word(slot0.index) {
|
||||
lines.push((format!("Requires {word}"), TooltipTint::Red));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(p) = cursor_ui else {
|
||||
return; // cursor off-window: nothing to seat against this frame
|
||||
};
|
||||
script.world_tooltip_gameobject(&template.name, &requirements, p.x, p.y);
|
||||
script.world_tooltip_gameobject(&template.name, &lines);
|
||||
*last = LastHover::Go(guid);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user