From 83d86e9b2f0866c33c9454f0306a94d2a09eb591 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 29 Jul 2026 19:53:26 +0700 Subject: [PATCH] locked doors start refusing, and indoor creatures appear already lit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-formats/src/lib.rs | 10 +- crates/benilla-formats/src/lock.rs | 196 ++++++- .../src/spells/catalog_tests.rs | 85 +++- crates/benilla-formats/src/spells/display.rs | 73 ++- crates/benilla-formats/src/spells/mod.rs | 46 +- .../src/messages/update_object/fields/mod.rs | 3 + .../messages/update_object/fields/player.rs | 8 + crates/benilla-ui/src/script/mod.rs | 1 + crates/benilla-ui/src/script/tooltip_unit.rs | 70 +-- crates/benilla/src/entities/attach/mod.rs | 6 +- crates/benilla/src/entities/equipment.rs | 8 +- crates/benilla/src/go_anim.rs | 52 +- crates/benilla/src/interior.rs | 291 +++++++++-- crates/benilla/src/mesh_tag.rs | 106 +++- crates/benilla/src/minimap/blips/dots.rs | 2 + crates/benilla/src/model_fade.rs | 190 +++++-- crates/benilla/src/player/camera.rs | 35 +- crates/benilla/src/target/click.rs | 290 +++++------ crates/benilla/src/target/cursor_mode.rs | 65 ++- crates/benilla/src/target/lock.rs | 480 ++++++++++++++++++ crates/benilla/src/target/mod.rs | 3 + crates/benilla/src/ui_action/mod.rs | 65 +++ crates/benilla/src/ui_tooltip.rs | 102 ++-- 23 files changed, 1743 insertions(+), 444 deletions(-) create mode 100644 crates/benilla/src/target/lock.rs diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 09f510f4..23797464 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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, diff --git a/crates/benilla-formats/src/lock.rs b/crates/benilla-formats/src/lock.rs index 070fac9c..844cfda4 100644 --- a/crates/benilla-formats/src/lock.rs +++ b/crates/benilla-formats/src/lock.rs @@ -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 { 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)); } } diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 37e3e4e1..476a3136 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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 diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 18165ffd..f8617230 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -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, + /// 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, + /// `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 { + 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 { + 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 diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 1b2605cf..2bc647d4 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -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 { 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 { 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) }), diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 157037bd..371ed550 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 0fb662b3..2fa9af17 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -92,6 +92,14 @@ impl ObjectFields { pub fn player_bank_bag_slot(&self, i: u8) -> Option { (i < 6).then(|| self.get_guid(FIELD_PLAYER_BANK_BAG_SLOT_1 + 2 * u16::from(i)))? } + /// `PLAYER_FIELD_KEYRING_SLOT_1 + 2i` — the keyring's 32 item guids (absolute inventory slots + /// 81–112). benilla ships no keyring *container* (see `ui_items`'s slot-order note), but the + /// guids stream like every other slot array and the GameObject lock chain needs them: dungeon + /// keys live here, and the reference's key-slot resolver scans the keyring alongside the bags + /// (wow-re `cursor-system.md` §8.4; decision 0752). `Some(0)` = empty. + pub fn player_keyring_slot(&self, i: u8) -> Option { + (i < 32).then(|| self.get_guid(FIELD_PLAYER_KEYRING_SLOT_1 + 2 * u16::from(i)))? + } /// `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 { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 953f75aa..fee5809d 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index f74aa0d9..e2c816df 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -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 ", - /// 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(()) }; diff --git a/crates/benilla/src/entities/attach/mod.rs b/crates/benilla/src/entities/attach/mod.rs index 5ee34fc0..74402941 100644 --- a/crates/benilla/src/entities/attach/mod.rs +++ b/crates/benilla/src/entities/attach/mod.rs @@ -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(), diff --git a/crates/benilla/src/entities/equipment.rs b/crates/benilla/src/entities/equipment.rs index 409a9335..d0a7b9d6 100644 --- a/crates/benilla/src/entities/equipment.rs +++ b/crates/benilla/src/entities/equipment.rs @@ -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(), }); } } diff --git a/crates/benilla/src/go_anim.rs b/crates/benilla/src/go_anim.rs index 71112248..c7aea95b 100644 --- a/crates/benilla/src/go_anim.rs +++ b/crates/benilla/src/go_anim.rs @@ -88,6 +88,17 @@ pub(crate) struct GoAnim { last_wire: Option, } +/// 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) -> 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, Added)>>, ) { 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::(); } 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 diff --git a/crates/benilla/src/interior.rs b/crates/benilla/src/interior.rs index d81a3310..e70da7d3 100644 --- a/crates/benilla/src/interior.rs +++ b/crates/benilla/src/interior.rs @@ -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 { + match (self.applied, &self.kind) { + (Some(AppliedLaw::Bake(_)), InteriorKind::Bake { material, .. }) => material, + _ => &self.exterior, + } + } + pub(crate) fn new(kind: InteriorKind, exterior: Handle) -> 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, + ) -> 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, parts: Query<(), With>, @@ -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