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