Files
benilla-octo/crates/benilla-formats/src/chr_classes.rs
T
SamandClaude Fable 5.1 feca23de3f the numerals kit goes stock, and a chat link is its own mouse target
- the status-bar numerals kit (`TextStatusBar.xml` + `.lua`) is the reference's own, and the attempt found a class of silent failure: `TextStatusBar_Initialize()` takes no argument in 1.12 and acts on `this`; ours took an optional bar, our unit frames passed one, and the reference's version ignores it — so the numerals would simply never appear, no error, nothing missing; seventeen of our signatures differ from the reference's that way (Lua drops a surplus argument in silence), written down before their files migrate, and the three "ours narrower" ones — `StaticPopup_Hide(which, data)`, `StaticPopup_FindVisible`, `WorldMapButton_OnClick(button, frame)` — closed now, because that direction bites when a STOCK file arrives and calls our version
- a chat link needs no mouse on its frame: wow-re read the client's ctors as mouse-enabling Button, CheckButton, Slider, ColorSelect and EditBox and NOT the scroll frames; the objection ("something makes a 1.12 chat link clickable") resolved as the engine synthesising one mouse-enabled `CSimpleHyperlinkButton` per link span (`0x7a3240`) — so a link span is a hit-test candidate on its own, the chat window is inert everywhere else, and the wheel is its own predicate rather than a borrowed mouse bit; seven `*ScrollFrame` known divergences retire
- the relic fit rule: `IsValidForSlot`'s slot-17 leg is an EQUALITY — `(InventoryType == RELIC) == hasRelicSlot` — so a relic lights the ranged slot for a druid and a bow does not, on the bag bar and the paper doll alike; closing it half-right would have lit the highlight for a druid dragging a bow
- a chain `.xml` whose `.lua` never loaded: `MoneyInputFrame.xml` had been on the chain without its `.lua`, so the template loaded and all ten `MoneyInputFrame_*` verbs read nil, silently — a gate now fails any chain `.xml` whose `.lua` sibling is neither sourced nor listed; the gap report's LOAD column carries the probe's collision caveat, its fx column stops crying wolf (it follows `<Script file=>`, stops reading `.Field(` as a global, subtracts a file's own locals), and a window is its xml PLUS the lua it sources

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:01:18 +07:00

209 lines
9.5 KiB
Rust

//! `ChrClasses.dbc` — the per-class client table, and the two columns anything here reads off it.
//!
//! One file, one parse. Both columns are answers the *engine* gives Lua about a class, and both
//! are read by address off the same class-indexed record table at `ds:0xc0def4`, bounded by the
//! max id at `ds:0xc0def8` (store `0xc0deec`, loader `0x542360`, filename `0x85838c`).
//!
//! ## Field 4 — the pet name token
//!
//! `HasPetSpells()`'s second return, and the only thing that decides whether the spellbook's
//! second tab reads "Pet" or "Demon" (decision 1032). `HasPetSpells 0x4b4410` pushes it verbatim:
//!
//! ```text
//! 0x4b445d eax = [player + 0x110] ; the descriptor fields
//! 0x4b4463 eax = byte [fields + 0x79] ; UNIT_FIELD_BYTES_0 byte 1 = the CLASS
//! 0x4b446b if (class < 0 || class > [0xc0def8]) -> the out-of-range arm
//! 0x4b4473 ecx = [0xc0def4] ; the class record table
//! 0x4b4479 eax = ecx[class] ; 1-BASED — index 0 is never a class
//! 0x4b447c edx = [rec + 0x10] ; <- the token string
//! 0x4b44a6 (player did not resolve) edx = "PET" ; the literal 0x846a40
//! ```
//!
//! **`rec + 0x10` is field 4, and the file says which field that is**: dumped from the real 5875
//! `ChrClasses.dbc` (9 rows, 17 columns, 0x44-byte records), field 4 is `"PET"` in every row except
//! Warlock (id 9), which is `"DEMON"`. Field 5 is the class name (`Warrior`/`Paladin`/…), so a
//! one-column slip would put "Warlock" on the tab — plausible enough to ship, which is why
//! [`tests`] anchors both columns.
//!
//! FrameXML then does `getglobal("PET_TYPE_"..token)` (`SpellBookFrame.lua:173`) against
//! `PET_TYPE_PET = "Pet"` / `PET_TYPE_DEMON = "Demon"` (`GlobalStrings.lua:3057-3058`) — so the
//! token is a **key**, never display text, and it is right that it is not localized here.
//!
//! ## Field 16 — the relic-slot flag
//!
//! `UnitHasRelicSlot 0x519e50` reads it and nothing else. Same walk — typemask bit 4 (PLAYER),
//! `UNIT_FIELD_BYTES_0` byte 1 for the class, bound against `0xc0def8`, index `0xc0def4` — then:
//!
//! ```text
//! 0x519ebb mov ecx,[row + 0x40] ; <- field 16, the relic flag
//! ```
//!
//! Non-zero pushes the **number 1.0**; zero pushes **nil** (never `false`). There is no `cmp`
//! against a class id anywhere in the function: 1.12 is entirely data-driven here, and this table
//! is the data. In the shipped file the column is 1 for exactly **2 Paladin, 7 Shaman, 11 Druid**
//! — Libram, Totem, Idol.
//!
//! **Why this was believed impossible, and the trap worth keeping.** The base `dbc.MPQ` copy of
//! this file is **16 fields / 64-byte records and has no field 16 at all**; `patch.MPQ` supersedes
//! it with the 17-field / 68-byte version, and the 5875 loader asserts exactly those two numbers
//! (`0x54240e`/`0x542446`), so the patch copy is the only one the client can read. Read the base
//! archive alone — or an early-vanilla 16-column struct — and the flag simply is not there, which
//! is how "the relic slot post-dates 1.12" became a settled belief in this codebase and stayed one
//! across two decision records. It is false; see decision 1796. [`CHR_CLASSES_FIELDS`] is what
//! keeps us on the patch copy, and it is load-bearing, not defensive.
//!
//! The flag is engine-enforced, not a UI conceit: `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg
//! requires `(InventoryType == 28 RELIC) == hasRelicSlot`, so **INVSLOT 17 takes a relic for those
//! three classes and a ranged weapon for everyone else** — one slot, two meanings. 1.12 has no
//! *separate* relic slot, which is the one true fragment inside the old belief.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
/// `ChrClasses.dbc`'s column count in 5875 (the header's `field_count`; `benilla-dbc` enforces it).
///
/// This is the **patch** copy's shape. The base archive's 16-field copy fails the check rather
/// than silently reading one column short of the relic flag — see the module header.
const CHR_CLASSES_FIELDS: usize = 17;
/// The pet-name-token column — byte `0x10` in the client's own record read, i.e. field 4.
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
/// The relic-slot column — byte `0x40` in `UnitHasRelicSlot`'s read, i.e. field 16.
const RELIC_SLOT_FIELD: usize = 0x40 / 4;
/// The literal the client pushes when the player object does not resolve (`0x846a40`). Also the
/// value nine of the ten class rows carry, which is why an unloaded table degrades invisibly.
pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
/// One class row, narrowed to the columns anything reads.
#[derive(Debug, Clone)]
struct ChrClass {
pet_name_token: Option<String>,
has_relic_slot: bool,
}
/// `ChrClasses.dbc` → class id ⇒ the columns we read.
#[derive(Debug, Default, Clone)]
pub struct ChrClasses(HashMap<u32, ChrClass>);
impl ChrClasses {
/// The pet name token for a class id, or the client's own [`PET_NAME_TOKEN_FALLBACK`] for a
/// class with no row. Mirrors `0x4b44a6`: the reference never answers nil here, only ever a
/// string.
pub fn pet_name_token(&self, class: u32) -> &str {
self.0
.get(&class)
.and_then(|c| c.pet_name_token.as_deref())
.unwrap_or(PET_NAME_TOKEN_FALLBACK)
}
/// Whether a class id's INVSLOT 17 is a relic slot — `UnitHasRelicSlot`'s whole body.
///
/// A class with no row answers `false`, which is the reference's own shape: its bound check
/// against `0xc0def8` falls to the nil leg, and an unloaded table reads every class as an
/// ordinary ranged wielder rather than inventing a relic slot for one.
pub fn has_relic_slot(&self, class: u32) -> bool {
self.0.get(&class).is_some_and(|c| c.has_relic_slot)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("ChrClasses");
for i in 0..CHR_CLASSES_FIELDS {
// Only the two read columns are typed; everything else stays an opaque dword. The class
// NAME block (field 5 up) is deliberately not decoded — nothing here displays it.
let ty = if i == PET_NAME_TOKEN_FIELD {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load `ChrClasses.dbc`'s read columns from the patch chain.
pub fn load_chr_classes(chain: &mut Chain) -> Result<ChrClasses> {
let bytes = chain
.read_file(CHR_CLASSES)
.with_context(|| format!("reading {CHR_CLASSES}"))?;
let rs = parse(&bytes, schema(), "ChrClasses.dbc")?;
let mut by_id = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
by_id.insert(
id,
ChrClass {
pet_name_token: str_at(&rs, r, PET_NAME_TOKEN_FIELD),
has_relic_slot: u32_at(r, RELIC_SLOT_FIELD).is_some_and(|v| v != 0),
},
);
}
Ok(ChrClasses(by_id))
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> Option<crate::chain::Chain> {
let data = crate::wow_data_or_skip!(None);
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 table, byte-anchored on the one column that matters. The neighbouring field
/// holds the class NAME, so a one-column slip reads "Warlock"/"Hunter" — which would still
/// resolve a `PET_TYPE_*` global lookup to nil and blank the tab, i.e. fail silently.
#[test]
fn warlocks_pet_is_a_demon_and_everyone_elses_is_a_pet() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
assert!(!t.is_empty());
assert_eq!(t.pet_name_token(9), "DEMON", "Warlock");
for (class, who) in [(1, "Warrior"), (3, "Hunter"), (11, "Druid")] {
assert_eq!(t.pet_name_token(class), "PET", "{who}");
}
// 6 and 10 have no row in 1.12 (Death Knight / Monk arrive later); the client's own
// out-of-range arm answers the literal, and so does this.
assert_eq!(t.pet_name_token(6), PET_NAME_TOKEN_FALLBACK);
assert_eq!(t.pet_name_token(0), PET_NAME_TOKEN_FALLBACK);
}
/// Field 16, anchored against the shipped file the same way — and against the belief it
/// replaces. Three classes, and exactly three: this asserts the whole column, because a
/// half-right answer here (say, Paladin alone) is the shape the old claim would decay into.
#[test]
fn libram_totem_and_idol_are_the_three_relic_classes() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
for (class, who) in [(2, "Paladin"), (7, "Shaman"), (11, "Druid")] {
assert!(t.has_relic_slot(class), "{who} carries a relic");
}
for (class, who) in [
(1, "Warrior"),
(3, "Hunter"),
(4, "Rogue"),
(5, "Priest"),
(8, "Mage"),
(9, "Warlock"),
] {
assert!(!t.has_relic_slot(class), "{who} wields a ranged weapon");
}
// No row, and no invented slot — the reference's nil leg.
assert!(!t.has_relic_slot(6));
assert!(!t.has_relic_slot(0));
}
}