Files
benilla-octo/crates/benilla-formats/src/itemsubclass.rs
T
SamandClaude Opus 5 c48d8c6b7e other players walk the world, and NPCs speak your name
- Other players walk the world. A watched player's motion between server updates now runs through
  the same grounded movement resolve as your own character — same capsule, same colliders, same
  step-up and ground election — so they follow terrain every frame instead of every packet, and
  stop at a wall instead of walking into it and being snapped back. Their jumps are swept against
  the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press
  the reference client broadcasts rather than defers.
- The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps
  ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under
  load — is decoded rather than skipped. Our own send path now matches the reference client's:
  facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so
  left and right can never ride the same packet.
- NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name,
  class and race, the gendered forms, and the server's live world states — and the same
  substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted
  draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable,
  where it used to open a dialog holding nothing but a placeholder greeting.
- Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive
  gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit
  equipped-item requirement named properly instead of by raw bits, and a buff's dispel class.
- The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve
  entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are
  named by their pet number rather than a creature-template entry, so the client asks the query the
  server can actually answer. And the asset extractor grew a stray-geometry census for tracking
  down geometry that renders where it should not.

This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said
nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:57:54 +07:00

400 lines
18 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.
//! ItemSubClass.dbc — per `(class, subclass)`: the alternate-proficiency fields and the
//! display gate the item tooltip's slot|type line reads (wow-re builder `0x52b650`, the
//! `0xc0db90` row cache).
//!
//! The builder consumes exactly three fields beyond the key: **prerequisiteProficiency@2 /
//! postrequisiteProficiency@3** (−1 = none; a weapon whose own subclass bit is missing from the
//! player's proficiency mask is still usable when the alternate's bit is set — the slot cell's
//! red instead of the type cell's), and **displayFlags@5** bit 0 (suppress the type name — the
//! "Miscellaneous" family: rings, trinkets, shirts never print an armor type).
//!
//! Record layout (no id column; keyed by class+subclass, 28 fields): class@0, subClass@1,
//! prerequisiteProficiency@2, postrequisiteProficiency@3, flags@4, displayFlags@5,
//! weaponParrySeq@6, weaponReadySeq@7, weaponAttackSeq@8, weaponSwingSize@9, displayName
//! 8+1 @10..18, verboseName 8+1 @19..27 — the offsets the builder reads (`[row+2]`, `[row+3]`,
//! byte of `[row+5]`, `[row+locale+10]`) land on exactly this shape.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{i32_at, parse, u32_at};
use crate::Chain;
const ITEM_SUB_CLASS: &str = "DBFilesClient\\ItemSubClass.dbc";
/// The group-name table read *before* [`ITEM_SUB_CLASS`] whenever a whole subclass **mask** needs a
/// name — see [`ItemSubClassCatalog::requirement_name`]. 11 fields (verified by loading the shipped
/// file): `ClassID@0`, `Mask@1`, `Name_Lang@2..9`, `NameFlags@10`.
const ITEM_SUB_CLASS_MASK: &str = "DBFilesClient\\ItemSubClassMask.dbc";
/// One row's tooltip-relevant fields.
#[derive(Debug, Clone, Copy)]
pub struct ItemSubClassInfo {
/// Alternate subclasses whose proficiency also permits use (−1 = none). The builder's
/// short-circuit: prerequisite wins when present; postrequisite is only consulted when
/// prerequisite is −1.
pub prerequisite_proficiency: i32,
pub postrequisite_proficiency: i32,
/// Bit 0 = never print the type name on the slot|type line.
pub display_flags: u32,
}
/// ItemSubClass.dbc keyed by `(class, subclass)`.
pub struct ItemSubClassCatalog {
rows: HashMap<(u32, u32), ItemSubClassInfo>,
/// The crafting book's header vocabulary (0437's TU-B fold-back): the resolved display name,
/// by the client's own byte law — **VerboseName** (`row + locale·4 + 0x4c`, enUS column 19)
/// when non-empty, else **DisplayName** (`+0x28`, column 10). "One-Handed Swords" over
/// "Sword"; plain "Cloth" where no verbose form exists.
names: HashMap<(u32, u32), String>,
/// **DisplayName** alone (column 10) — the SINGULAR spelling. The two are not
/// interchangeable, and the reference picks between them by call site: the cast-fail line
/// `SPELL_FAILED_EQUIPPED_ITEM_CLASS` reads "Must have a **Wand** equipped" where the spell
/// tooltip's requirement line reads "Requires **Wands**" — both pinned by reference captures
/// of the same spell (Shoot 5019: class 2, submask bit 19).
display_names: HashMap<(u32, u32), String>,
/// Every `(class, subclass)` key in **file order** — the order the reference walks the rows in,
/// which decides both the comma-join's order and which row counts as "first"
/// ([`Self::requirement_name`] / [`Self::requirement_display_name`]).
order: Vec<(u32, u32)>,
/// `ItemSubClassMask.dbc`: `(classId, mask, name)` — the group names that stand in for a whole
/// mask ("Melee Weapon" for the eleven melee weapon subclasses). Only 3 rows ship. Folded in
/// here rather than given its own catalog because it is stage 1 of one lookup, not a second
/// vocabulary.
mask_groups: Vec<(u32, u32, String)>,
}
impl ItemSubClassCatalog {
/// The alternate proficiency subclass for `(class, subclass)` — the builder's exact
/// sentinel walk: prerequisite if not −1, else postrequisite if not −1, else `None`.
pub fn proficiency_alt(&self, class: u32, subclass: u32) -> Option<u32> {
let r = self.rows.get(&(class, subclass))?;
[r.prerequisite_proficiency, r.postrequisite_proficiency]
.into_iter()
.find(|&v| v != -1)
.map(|v| v as u32)
}
/// The subclass display name (verbose-first, the wow-re `tradeskill` node's byte law) — the
/// crafting book's group header text; `None` for an unknown key.
pub fn name(&self, class: u32, subclass: u32) -> Option<&str> {
self.names.get(&(class, subclass)).map(String::as_str)
}
/// The SINGULAR subclass name (DisplayName only — [`Self::display_names`]); `None` for an
/// unknown key or an empty column.
pub fn display_name(&self, class: u32, subclass: u32) -> Option<&str> {
self.display_names
.get(&(class, subclass))
.map(String::as_str)
}
/// What a spell's equipped-item requirement is *called*, for `(class, subclass_mask)` — the
/// **plural/verbose** spelling the spell tooltip prints ("Requires Wands", "Requires Melee
/// Weapon"). `None` when nothing names it.
///
/// The reference's law is two-stage (wow-re `tooltip-content-law.md` §3-EQUIPITEM, carved from
/// `0x6e2380` and its two call sites), and both stages matter for a mask with several bits set:
///
/// 1. `ItemSubClassMask.dbc` on **exact whole-mask equality** — not "any bit", the entire mask.
/// Three rows ship: `{2, 0x2a5f3, "Melee Weapon"}`, `{4, 0x60, "Shield"}`,
/// `{2, 0x4000c, "Ranged Weapon"}`. This is why Parry (`0x2a5f3` = eleven melee subclasses)
/// prints one group name rather than eleven weapon types.
/// 2. Otherwise every subclass the mask names, comma-joined in file order, each preferring
/// VerboseName with a DisplayName fallback ([`Self::name`]).
///
/// Only when stage 2 also finds nothing is the line absent. Note `0x6e2380` reads **only**
/// ItemSubClass.dbc — there is no ItemClass.dbc fallback on either path.
pub fn requirement_name(&self, class: u32, mask: u32) -> Option<String> {
self.mask_group(class, mask)
.map(str::to_string)
.or_else(|| {
let joined = self
.masked(class, mask)
.filter_map(|key| self.names.get(&key))
.cloned()
.collect::<Vec<_>>()
.join(", ");
(!joined.is_empty()).then_some(joined)
})
}
/// The **singular** spelling of the same requirement — what the `SPELL_FAILED_EQUIPPED_ITEM_CLASS`
/// cast-fail line says ("Must have a Wand equipped"). Same stage 1, but stage 2 takes only the
/// FIRST matching subclass and its DisplayName: never a join, never VerboseName.
pub fn requirement_display_name(&self, class: u32, mask: u32) -> Option<String> {
self.mask_group(class, mask)
.map(str::to_string)
.or_else(|| {
self.masked(class, mask)
.find_map(|key| self.display_names.get(&key))
.cloned()
})
}
/// Stage 1: the `ItemSubClassMask.dbc` group name for an EXACT `(class, mask)` pair.
fn mask_group(&self, class: u32, mask: u32) -> Option<&str> {
self.mask_groups
.iter()
.find(|(c, m, _)| *c == class && *m == mask)
.map(|(_, _, name)| name.as_str())
}
/// The catalog's keys for `class` whose subclass bit is set in `mask`, in file order.
fn masked(&self, class: u32, mask: u32) -> impl Iterator<Item = (u32, u32)> + '_ {
self.order
.iter()
.copied()
.filter(move |&(c, sub)| c == class && sub < 32 && mask & (1 << sub) != 0)
}
/// Whether the type name is suppressed (displayFlags bit 0).
pub fn hides_name(&self, class: u32, subclass: u32) -> bool {
self.rows
.get(&(class, subclass))
.is_some_and(|r| r.display_flags & 1 != 0)
}
/// Number of rows (for logging/diagnostics).
pub fn len(&self) -> usize {
self.rows.len()
}
/// Whether no rows loaded.
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
fn item_sub_class_schema() -> Schema {
let mut s = Schema::new("ItemSubClass");
s.add_field(SchemaField::new("Class", FieldType::UInt32));
s.add_field(SchemaField::new("SubClass", FieldType::UInt32));
s.add_field(SchemaField::new(
"PrerequisiteProficiency",
FieldType::Int32,
));
s.add_field(SchemaField::new(
"PostrequisiteProficiency",
FieldType::Int32,
));
s.add_field(SchemaField::new("Flags", FieldType::UInt32));
s.add_field(SchemaField::new("DisplayFlags", FieldType::UInt32));
for name in ["ParrySeq", "ReadySeq", "AttackSeq", "SwingSize"] {
s.add_field(SchemaField::new(format!("Weapon{name}"), FieldType::UInt32));
}
for i in 0..8 {
s.add_field(SchemaField::new(
format!("DisplayName{i}"),
FieldType::String,
));
}
s.add_field(SchemaField::new("DisplayNameFlags", FieldType::UInt32));
for i in 0..8 {
s.add_field(SchemaField::new(
format!("VerboseName{i}"),
FieldType::String,
));
}
s.add_field(SchemaField::new("VerboseNameFlags", FieldType::UInt32));
s
}
fn item_sub_class_mask_schema() -> Schema {
let mut s = Schema::new("ItemSubClassMask");
s.add_field(SchemaField::new("ClassID", FieldType::UInt32));
s.add_field(SchemaField::new("Mask", FieldType::UInt32));
for i in 0..8 {
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
}
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
s
}
/// Load ItemSubClass.dbc — plus the ItemSubClassMask.dbc group names, which are stage 1 of the same
/// lookup ([`ItemSubClassCatalog::requirement_name`]) — from the patch chain.
pub fn load_item_sub_classes(chain: &mut Chain) -> Result<ItemSubClassCatalog> {
let bytes = chain
.read_file(ITEM_SUB_CLASS)
.with_context(|| format!("reading {ITEM_SUB_CLASS}"))?;
let rs = parse(&bytes, item_sub_class_schema(), "ItemSubClass")?;
let mask_bytes = chain
.read_file(ITEM_SUB_CLASS_MASK)
.with_context(|| format!("reading {ITEM_SUB_CLASS_MASK}"))?;
let mask_rs = parse(
&mask_bytes,
item_sub_class_mask_schema(),
"ItemSubClassMask",
)?;
let mut mask_groups = Vec::with_capacity(mask_rs.records().len());
for r in mask_rs.records() {
let (Some(class), Some(mask), Some(name)) = (
u32_at(r, 0),
u32_at(r, 1),
crate::dbc::str_at(&mask_rs, r, 2).filter(|n| !n.is_empty()),
) else {
continue;
};
mask_groups.push((class, mask, name));
}
let mut rows = HashMap::with_capacity(rs.records().len());
let mut names = HashMap::with_capacity(rs.records().len());
let mut display_names = HashMap::with_capacity(rs.records().len());
let mut order = Vec::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(class), Some(subclass)) = (u32_at(r, 0), u32_at(r, 1)) else {
continue;
};
order.push((class, subclass));
rows.insert(
(class, subclass),
ItemSubClassInfo {
prerequisite_proficiency: i32_at(r, 2).unwrap_or(-1),
postrequisite_proficiency: i32_at(r, 3).unwrap_or(-1),
display_flags: u32_at(r, 5).unwrap_or(0),
},
);
// VerboseName enUS (col 19) first, DisplayName enUS (col 10) fallback — the struct doc's
// byte law. Empty both → no name row (the header renders blank, faithfully unlikely).
let display = crate::dbc::str_at(&rs, r, 10).filter(|n| !n.is_empty());
let name = crate::dbc::str_at(&rs, r, 19)
.filter(|n| !n.is_empty())
.or_else(|| display.clone());
if let Some(name) = name {
names.insert((class, subclass), name);
}
if let Some(display) = display {
display_names.insert((class, subclass), display);
}
}
Ok(ItemSubClassCatalog {
rows,
names,
display_names,
order,
mask_groups,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The header-name law on the real 5875 file (0446's TU-B fold-back): verbose-first,
/// display fallback. Skips without client data.
#[test]
fn real_subclass_names_resolve_verbose_first() {
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_item_sub_classes(&mut chain).expect("load ItemSubClass.dbc");
assert_eq!(cat.name(2, 7), Some("One-Handed Swords"), "verbose wins");
assert_eq!(cat.name(4, 1), Some("Cloth"));
assert_eq!(
cat.name(5, 0),
Some("Reagent"),
"display fallback when verbose empty"
);
assert_eq!(cat.name(0, 0), Some("Consumable"));
assert_eq!(cat.name(99, 0), None);
}
/// The two-stage equipped-item requirement law (§3-EQUIPITEM) against the real 5875 DBCs —
/// including the multi-bit case we used to give up on. Skips without client data.
#[test]
fn real_requirement_names_take_the_group_before_the_join() {
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_item_sub_classes(&mut chain).expect("load the two DBCs");
// Stage 1 — the whole mask has a name of its own. All three shipped rows.
assert_eq!(
cat.requirement_name(2, 0x0002_a5f3).as_deref(),
Some("Melee Weapon"),
"Parry's eleven melee subclasses are ONE group name, not a join"
);
assert_eq!(cat.requirement_name(4, 0x60).as_deref(), Some("Shield"));
assert_eq!(
cat.requirement_name(2, 0x0004_000c).as_deref(),
Some("Ranged Weapon")
);
// The group name is the same string in the singular arm — the stages share stage 1.
assert_eq!(
cat.requirement_display_name(2, 0x0002_a5f3).as_deref(),
Some("Melee Weapon")
);
// Stage 2, one bit — the two spellings diverge (Shoot 5019: class 2, bit 19).
assert_eq!(cat.requirement_name(2, 1 << 19).as_deref(), Some("Wands"));
assert_eq!(
cat.requirement_display_name(2, 1 << 19).as_deref(),
Some("Wand")
);
// Stage 2, several bits with no group row — joined for the tooltip, first-only for the
// cast-fail line. Daggers (15) + fist weapons (13) is not a shipped group.
let mask = (1 << 13) | (1 << 15);
assert_eq!(
cat.requirement_name(2, mask).as_deref(),
Some("Fist Weapons, Daggers")
);
assert_eq!(
cat.requirement_display_name(2, mask).as_deref(),
Some("Fist Weapon")
);
// Nothing to name.
assert_eq!(cat.requirement_name(2, 0), None);
assert_eq!(cat.requirement_name(99, 1), None);
}
/// Data-gated on the real 5875 DBC. Prints the live prereq/postreq and displayFlags rows so
/// a schema slip is visible, and pins the known shape. Skips without client data.
#[test]
fn item_sub_classes_load_from_the_chain() {
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_item_sub_classes(&mut chain).expect("ItemSubClass.dbc loads");
assert!(!cat.is_empty());
// The live alt pairs come in prerequisite/postrequisite couples per weapon family:
// 2H Axe (2,1) ← 1H Axe via prerequisite, 1H Mace (2,4) → 2H Mace via POSTrequisite
// (the sentinel short-circuit's second leg), 2H Sword (2,8) ← 1H Sword, and Shield
// (4,6) ← Buckler. Print the full list so a schema slip is visible.
for ((c, sc), r) in {
let mut v: Vec<_> = cat.rows.iter().map(|(&k, v)| (k, *v)).collect();
v.sort_by_key(|&((c, sc), _)| (c, sc));
v
} {
if r.prerequisite_proficiency != -1 || r.postrequisite_proficiency != -1 {
eprintln!(
"alt: class {c} sub {sc} pre {} post {}",
r.prerequisite_proficiency, r.postrequisite_proficiency
);
}
}
assert_eq!(cat.proficiency_alt(2, 1), Some(0));
assert_eq!(cat.proficiency_alt(2, 4), Some(5));
assert_eq!(cat.proficiency_alt(2, 8), Some(7));
assert_eq!(cat.proficiency_alt(4, 6), Some(5));
// Daggers stand alone — no other proficiency softens a dagger's red.
assert_eq!(cat.proficiency_alt(2, 15), None);
// displayFlags bit 0: Miscellaneous armor (rings/trinkets/shirts) hides its type name;
// ordinary armor and weapons show theirs.
assert!(cat.hides_name(4, 0));
assert!(!cat.hides_name(4, 1), "Cloth prints");
assert!(!cat.hides_name(2, 7), "Sword prints");
}
}