Files
benilla-octo/crates/benilla-formats/src/lock.rs
T
SamandClaude Fable 5 6b980b9efb the player build: one binary that carries its content
- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module
- the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder
- the glue screens get a capture shutter, and the engine stops depending on the protocol crate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:02 +07:00

380 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Lock.dbc — the requirements a lockable GameObject (or item) carries (decision 0239). A
//! GameObject's template `lockId` (a type-specific slot of its query `data[]`) indexes this table;
//! each lock has up to 8 requirement **slots**. Interacting with a locked object casts a *known*
//! spell whose `SPELL_EFFECT_OPEN_LOCK` `EffectMiscValue` matches a **skill** slot's `LockType`
//! index (mining / herbalism / lockpicking), or consumes the **item** slot's key. A `lockId` of 0,
//! or a row whose every slot is empty, means "no lock" — the object opens by `CMSG_GAMEOBJ_USE`
//! instead of a cast (the split the RE pinned; see `wow-5875-re` cursor-system.md §8).
//!
//! 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`.
//!
//! **`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;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
const LOCK: &str = "DBFilesClient\\Lock.dbc";
/// The file's column count (must equal the DBC header `field_count` — `benilla-dbc` enforces it).
const LOCK_FIELDS: usize = 33;
/// A lock has up to 8 requirement slots (`MAX_LOCK_CASE`).
pub const MAX_LOCK_SLOTS: usize = 8;
/// `Lock.dbc` `Type[i]` — a slot's key kind (mangos `LockKeyType`).
pub const LOCK_KEY_NONE: u32 = 0;
/// The slot is opened by holding a **key item**; `LockSlot::index` is that item's entry.
pub const LOCK_KEY_ITEM: u32 = 1;
/// The slot is opened by a **skill** (mining / herbalism / lockpicking); `LockSlot::index` is the
/// `LockType.dbc` index the opener spell's `EffectMiscValue` must match, `LockSlot::skill` the
/// required skill value.
pub const LOCK_KEY_SKILL: u32 = 2;
/// One of a lock's up-to-8 requirement slots. An all-zero slot is empty (unused).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LockSlot {
/// `LOCK_KEY_NONE` / `LOCK_KEY_ITEM` / `LOCK_KEY_SKILL`.
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 — 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.
pub struct LockCatalog {
locks: HashMap<u32, [LockSlot; MAX_LOCK_SLOTS]>,
}
impl LockCatalog {
/// Build a catalog from explicit rows — tests and tools; the game path is
/// [`load_lock_catalog`].
pub fn from_rows(rows: impl IntoIterator<Item = (u32, [LockSlot; MAX_LOCK_SLOTS])>) -> Self {
Self {
locks: rows.into_iter().collect(),
}
}
/// The 8 requirement slots for a `lockId`, or `None` if the id isn't in the table (treat as no
/// lock). A returned row may still be all-empty — [`LockCatalog::is_locked`] is the "must cast"
/// test.
pub fn slots(&self, lock_id: u32) -> Option<&[LockSlot; MAX_LOCK_SLOTS]> {
self.locks.get(&lock_id)
}
/// Whether a `lockId` names a real lock (present, with at least one non-empty slot) — i.e. the
/// object opens by a cast, not `CMSG_GAMEOBJ_USE`. A `0` id or an absent/all-empty row is "no
/// lock".
pub fn is_locked(&self, lock_id: u32) -> bool {
lock_id != 0
&& self
.locks
.get(&lock_id)
.is_some_and(|s| s.iter().any(|slot| slot.key_type != LOCK_KEY_NONE))
}
pub fn len(&self) -> usize {
self.locks.len()
}
pub fn is_empty(&self) -> bool {
self.locks.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("Lock");
for i in 0..LOCK_FIELDS {
s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32));
}
s
}
/// Load Lock.dbc from the patch chain into a [`LockCatalog`].
pub fn load_lock_catalog(chain: &mut Chain) -> Result<LockCatalog> {
let bytes = chain
.read_file(LOCK)
.with_context(|| format!("reading {LOCK}"))?;
let rs = parse(&bytes, schema(), "Lock.dbc")?;
let mut locks = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let mut slots = [LockSlot::default(); MAX_LOCK_SLOTS];
for (i, slot) in slots.iter_mut().enumerate() {
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);
}
Ok(LockCatalog { locks })
}
#[cfg(test)]
mod tests {
use super::*;
/// The Type/Index/Skill column offsets on the real build-5875 `Lock.dbc` — a column slip fails
/// loudly. Anchors: Copper Vein (lockId 38, a Mining node) and Silverleaf (lockId 29, an
/// Herbalism node), both skill 1 — the lowest of their profession. Skips without client data.
#[test]
fn real_lock_catalog_reads_skill_slots() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_lock_catalog(&mut chain).expect("load Lock.dbc");
assert!(!cat.is_empty(), "Lock.dbc parsed empty");
// Copper Vein (gameobject_template 1731, chest.data0 = lockId 38): a MINING skill lock. The
// real row is `{Type[0]=2 SKILL, Index[0]=3 (Mining LockType), Skill[0]=0}` — `Type[0]` at
// col 1, `Index[0]` at col 9, `Skill[0]` at col 17; a column slip lands elsewhere. (Lock.dbc
// stores 0 in `Skill` for gathering nodes — the profession *spell* is the gate, not a value
// here; the server enforces the node's grey level.)
let vein = cat.slots(38).expect("lockId 38 (Copper Vein)");
assert_eq!(
vein[0].key_type, LOCK_KEY_SKILL,
"copper vein is a skill lock"
);
assert_eq!(vein[0].index, 3, "Mining is LockType index 3");
assert_eq!(
vein[0].skill, 0,
"gathering nodes carry no Skill value in Lock.dbc"
);
assert!(
vein[1..].iter().all(|s| s.key_type == LOCK_KEY_NONE),
"one slot only"
);
assert!(
cat.is_locked(38),
"a mining vein is a real lock (must cast, not USE)"
);
// Silverleaf (gameobject_template 1617, lockId 29): an HERBALISM skill lock — LockType index
// 2, distinct from mining's 3.
let herb = cat.slots(29).expect("lockId 29 (Silverleaf)");
assert_eq!(
herb[0].key_type, LOCK_KEY_SKILL,
"silverleaf is a skill lock"
);
assert_eq!(herb[0].index, 2, "Herbalism is LockType index 2");
assert_ne!(herb[0].index, vein[0].index, "herbalism ≠ mining LockType");
// A lockId of 0 is never a lock (opens by USE, not a cast).
assert!(!cat.is_locked(0));
}
/// The keyless-chest path: lockId 43 (Sunken Chest, Storage Chest, Worn Wooden Chest, …) is a
/// single SKILL slot naming LockType **13** — the one spell 6478 "Opening" opens, and "Opening"
/// is a default-known player spell, so any character can loot these. Confirms the routing matches
/// keyless chests (not just gathering nodes) to a spell the player already has. Skips without data.
#[test]
fn real_lock_catalog_reads_keyless_chest() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_lock_catalog(&mut chain).expect("load Lock.dbc");
let chest = cat.slots(43).expect("lockId 43 (simple chest)");
assert!(
cat.is_locked(43),
"even a keyless chest is a real lock (cast, not USE)"
);
// The requirement sits in slot 1 here (slot 0 empty) — the routing must scan all 8 slots,
// not just slot 0. LockType 13 = the "Opening" the default spell 6478 provides.
assert_eq!(chest[1].key_type, LOCK_KEY_SKILL);
assert_eq!(
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 = crate::wow_data_or_skip!();
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));
}
}