Files
benilla-octo/crates/benilla-formats/src/anim_data.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

196 lines
8.5 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.
//! `AnimationData.dbc` — the per-animation policy row behind the sheath reconcile (decision 0080)
//! and, later, missing-clip fallback resolution.
//!
//! Layout — VERIFIED against build 5875 (header + full 208-row decode with name joins,
//! 2026-07-03): **208 × 7 × 28 B**: `ID(0), Name(str, 1), WeaponFlags(2), BodyFlags(3), _col4(4),
//! _col5(5), Fallback(6)`.
//!
//! - **WeaponFlags (col 2)** — the sheath-reconcile bits the client tests on every `PlayAnimation`
//! (`0x5fdf80`). The identification is **byte-verified** (wow-re `sheath-policy.md` §6a, §5
//! cross-checked): the reconcile indexes the `WowClientDB<AnimationData>` cache (`0xc0e070`,
//! loader-asserted 7 columns × 28 B against `DBFilesClient\AnimationData.dbc`) by the current
//! sequence's AnimationData id, and all three flag tests read row offset `+0x8` = column 2.
//! The bits: `4` force-stow (casts, Swim 42–45, Mount 91,
//! SitChair 102–104, Loot 50), `0x10` force-stow (every Emote, AttackUnarmed 16/117,
//! SitGround 96–98, Kneel 114–116, AttackBow/Rifle/Thrown), `0x20` force-draw-melee (armed
//! attacks 17–19/85–88, Ready1H/2H/2HL 26–28, specials, FishingCast/Loop 133–134). The column's
//! exact 5875 value set is `{0, 4, 16, 20, 32}` — precisely the tested bit triple.
//! - **Fallback (col 6)** — identified from the data (the chains are unambiguous:
//! Attack2H→Attack1H→AttackUnarmed, Sprint→Run, JumpLandRun→Run, Sleep→SleepDown, Drowned→Drown,
//! WalkBackwards→Walk, StealthWalk→Walk; col 5 read as a fallback yields nonsense — Attack2H→Stop,
//! Sleep→SpellCast). **Not the primary runtime missing-clip mechanism** (decision 0082, amending
//! this file's earlier reading): the real client's per-id fallback for ids `< 203` is a single
//! indexed read of the M2's own baked `PlayableAnimationLookup` (a per-model precomputed cache of
//! *this* column's walk against that model's actual sequence set — see
//! `benilla_formats::PlayableAnim` / `benilla_assets::ModelAnimations::resolve`). This column is
//! PATH 2 of that resolver (ids ≥ 203 only, `AnimationData.dbc`'s 208 rows) and the *source* the
//! baked tables were computed from. `0` = fall back to Stand.
//! - Cols 4/5 are unidentified here (4 reads priority-like — 500 on Death/Dead/Drown, 10 on
//! locomotion; 5 flag-like) and are not exposed.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
/// One animation's policy row.
#[derive(Clone, Copy)]
pub struct AnimEntry {
/// The sheath-reconcile bits (see the module doc). `0` for most rows.
pub weapon_flags: u32,
/// The substitute animation id when a model lacks this clip (`0` = Stand).
pub fallback: u16,
}
/// `AnimationData.dbc` id → its policy row.
pub struct AnimDataCatalog {
rows: HashMap<u16, AnimEntry>,
/// id → the row's Name string (col 1, `"Attack1H"`) — the human face for debug readouts
/// (the inspector's anim line). Beside [`AnimEntry`] so the policy row stays `Copy`.
names: HashMap<u16, String>,
}
impl AnimDataCatalog {
/// Build a catalog directly from rows — the synthetic-table entry point for tests exercising
/// missing-clip resolution's PATH 2 walk (decision 0082, `benilla_assets::ModelAnimations::resolve`)
/// without a real `AnimationData.dbc`.
pub fn from_rows(rows: impl IntoIterator<Item = (u16, AnimEntry)>) -> Self {
Self {
rows: rows.into_iter().collect(),
names: HashMap::new(),
}
}
/// The sheath-reconcile WeaponFlags for an animation id (`0` for an unknown id — no policy).
pub fn weapon_flags(&self, id: u16) -> u32 {
self.rows.get(&id).map_or(0, |r| r.weapon_flags)
}
/// The fallback animation for a model lacking clip `id` — `None` when the row has none
/// (fallback 0 = Stand, left to the caller's own id-list fallbacks) or the id is unknown.
pub fn fallback(&self, id: u16) -> Option<u16> {
self.rows
.get(&id)
.map(|r| r.fallback)
.filter(|&f| f != 0 && f != id)
}
/// The row's Name string (`"Run"`, `"Attack1H"`) — `None` for an unknown id or a
/// synthetic-table catalog ([`Self::from_rows`] keeps no names).
pub fn name(&self, id: u16) -> Option<&str> {
self.names.get(&id).map(String::as_str)
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("AnimationData");
for i in 0..7 {
let ty = if i == 1 {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("f{i}"), ty));
}
s
}
/// Read `AnimationData.dbc` off the patch chain.
pub fn load_anim_data_catalog(chain: &mut Chain) -> Result<AnimDataCatalog> {
let bytes = chain
.read_file("DBFilesClient\\AnimationData.dbc")
.context("reading AnimationData.dbc")?;
let rs = parse(&bytes, schema(), "AnimationData")?;
let mut rows = HashMap::with_capacity(rs.records().len());
let mut names = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
rows.insert(
id as u16,
AnimEntry {
weapon_flags: u32_at(r, 2).unwrap_or(0),
fallback: u32_at(r, 6).unwrap_or(0) as u16,
},
);
if let Some(name) = str_at(&rs, r, 1).filter(|n| !n.is_empty()) {
names.insert(id as u16, name);
}
}
Ok(AnimDataCatalog { rows, names })
}
#[cfg(test)]
mod tests {
use super::*;
/// The real 5875 table: the WeaponFlags value set is exactly the reconcile's tested bit
/// triple, the decision-0080 spot rows carry the recorded bits, and the fallback chains
/// resolve (Attack2H→Attack1H, Sprint→Run). Skips without client data.
#[test]
fn real_animation_data_decodes() {
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_anim_data_catalog(&mut chain).expect("load AnimationData");
assert_eq!(cat.len(), 208);
// The whole column stays within the tested bit triple {0, 4, 0x10, 0x14, 0x20}.
for (&id, row) in &cat.rows {
assert!(
matches!(row.weapon_flags, 0 | 4 | 0x10 | 0x14 | 0x20),
"anim {id}: unexpected WeaponFlags {:#x}",
row.weapon_flags
);
}
// Decision 0080's transcribed highlights.
assert_eq!(cat.weapon_flags(42), 4, "Swim force-stows");
assert_eq!(cat.weapon_flags(91), 4, "Mount force-stows");
assert_eq!(cat.weapon_flags(102), 4, "SitChairLow force-stows");
assert_eq!(cat.weapon_flags(60), 0x10, "EmoteTalk force-stows");
assert_eq!(
cat.weapon_flags(16),
0x10,
"AttackUnarmed needs empty hands"
);
assert_eq!(cat.weapon_flags(96), 0x10, "SitGroundDown force-stows");
assert_eq!(cat.weapon_flags(17), 0x20, "Attack1H force-draws");
assert_eq!(cat.weapon_flags(26), 0x20, "Ready1H force-draws");
assert_eq!(cat.weapon_flags(133), 0x20, "FishingCast force-draws");
assert_eq!(cat.weapon_flags(0), 0, "Stand carries no policy");
// The ranged-handling trio the reconcile exempts (0x69/0x6a/0x70).
for id in [105, 106, 112] {
assert_eq!(cat.weapon_flags(id), 0, "Load* anims carry no policy");
}
// The name column (the inspector's anim line).
assert_eq!(cat.name(0), Some("Stand"));
assert_eq!(cat.name(5), Some("Run"));
assert_eq!(cat.name(17), Some("Attack1H"));
assert_eq!(cat.name(255), None, "unknown id has no name");
// The fallback chains.
assert_eq!(cat.fallback(18), Some(17), "Attack2H → Attack1H");
assert_eq!(cat.fallback(17), Some(16), "Attack1H → AttackUnarmed");
assert_eq!(cat.fallback(143), Some(5), "Sprint → Run");
assert_eq!(cat.fallback(187), Some(5), "JumpLandRun → Run");
assert_eq!(cat.fallback(100), Some(99), "Sleep → SleepDown");
assert_eq!(cat.fallback(4), None, "Walk falls back to Stand (0 → None)");
}
}