A26: чтение анимационного файла .ani (кадры)
Some checks are pending
CI / checks (push) Waiting to run

formats: mdl.animations() — описания анимаций (mstudioanimdesc_t) с определением
размера записи по данным (как у секвенций); anim_block_names() — имена
анимационных файлов; anim_blocks() — границы блоков внутри .ani.

Замеры: у m_anm.mdl 1563 анимации (@reference 101 кадр, @idle_all_01 280,
@idle_all_02 151, все 30 fps), 247 блоков, и ВСЕ имеют anim_block > 0 — то есть
кадры не в MDL, а в отдельном файле. Файл models/m_anm.ani прочитан целиком:
7,1 МБ. В конце таблицы имён блоков есть пустая запись — чтение по ней падает и
пишет предупреждение, это безвредно.

Дальше: декодирование mstudioanim_t (6 каналов на кость, дельты с накоплением,
повороты кватернионом), позы костей с учётом родителей, интерполяция между
кадрами и выбор секвенции по состоянию.

Все 23 сьюта зелёные; docs/PARITY.md A26 обновлён.
This commit is contained in:
Niko Marmeladkov 2026-10-17 11:40:57 +03:00
parent 3c0bef5169
commit 2c7ec501e2
3 changed files with 156 additions and 3 deletions

View file

@ -140,6 +140,44 @@ pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result<Mod
continue;
};
let sequences = included.sequences().unwrap_or_default();
if let Ok(anims) = included.animations() {
let block_names = included.anim_block_names();
let blocks = included.anim_blocks();
// Кадры GMod-моделей лежат в отдельном `.ani` — читаем его
// целиком: он нужен для поз костей (A26, следующий шаг).
let mut ani_bytes = 0usize;
for block_name in &block_names {
let ani_path = if block_name.starts_with("models/") {
block_name.clone()
} else {
format!("models/{block_name}")
};
match vfs.read(&ani_path) {
Ok(data) => {
ani_bytes += data.len();
tracing::info!(
include = %path,
ani = %ani_path,
bytes = data.len(),
"анимационный файл прочитан"
);
}
Err(err) => tracing::warn!(
include = %path,
ani = %ani_path,
"анимационный файл не прочитан: {err}"
),
}
}
tracing::info!(
include = %path,
animations = anims.len(),
block_names = ?block_names,
blocks = blocks.len(),
ani_bytes,
"анимации включённой модели"
);
}
tracing::info!(
include = %path,
numlocalseq = included.header.numlocalseq,

View file

@ -59,6 +59,22 @@ pub enum MdlError {
Kv(#[from] crate::kv::KvError),
}
/// Описание анимации (`mstudioanimdesc_t`).
///
/// Кадры лежат либо в самом MDL (`anim_block == 0`), либо в отдельном `.ani`,
/// имя которого лежит в анимационных блоках модели (у GMod-моделей второе).
#[derive(Debug, Clone, Default)]
pub struct AnimDesc {
pub name: String,
pub fps: f32,
pub flags: i32,
pub num_frames: i32,
pub anim_block: i32,
pub anim_index: i32,
pub num_movements: i32,
pub movement_index: i32,
}
/// Секвенция анимации (`mstudioseqdesc_t`).
///
/// Читаются поля, нужные для первого среза: имя (для выбора по состоянию),
@ -501,6 +517,90 @@ impl<'a> Mdl<'a> {
}
}
/// Имена анимационных блоков — таблица строк через NUL.
///
/// У GMod-моделей кадры лежат не в MDL, а в отдельном `.ani`; какой именно —
/// написано здесь (`szanimblocknameindex`).
pub fn anim_block_names(&self) -> Vec<String> {
let h = &self.header;
let mut out = Vec::new();
let mut offset = h.szanimblocknameindex;
if offset <= 0 || offset as usize >= self.data.len() {
return out;
}
while (offset as usize) < self.data.len() && out.len() < 16 {
let start = offset as usize;
let Some(rest) = self.data.get(start..) else {
break;
};
let end = rest.iter().position(|b| *b == 0).unwrap_or(rest.len());
let name = String::from_utf8_lossy(&rest[..end]).to_string();
if name.is_empty() {
break;
}
out.push(name);
offset = (start + end + 1) as i32;
}
out
}
/// Блоки анимационного файла: границы данных для `anim_block` анимации.
pub fn anim_blocks(&self) -> Vec<(i32, i32)> {
let h = &self.header;
let mut out = Vec::with_capacity(h.numanimblocks.max(0) as usize);
for i in 0..h.numanimblocks.max(0) as usize {
let base = h.animblockindex.max(0) as usize + i * 8;
out.push((
i32_at(self.data, base).unwrap_or(0),
i32_at(self.data, base + 4).unwrap_or(0),
));
}
out
}
/// Описания анимаций (`mstudioanimdesc_t`).
///
/// Как и у секвенций, размер записи версионно-зависим (96 у v48, 100 у
/// v49), поэтому шаг **определяется по данным**: принимается тот, при
/// котором имя читается у всех анимаций.
pub fn animations(&self) -> Result<Vec<AnimDesc>, MdlError> {
let h = &self.header;
let count = h.numlocalanim.max(0) as usize;
if count == 0 {
return Ok(Vec::new());
}
let start = h.localanimindex.max(0) as usize;
for stride in (88..=120).step_by(4) {
let mut out = Vec::with_capacity(count);
let mut ok = true;
for i in 0..count {
let base = start + i * stride;
let Some(name_offset) = i32_at(self.data, base + 4) else {
ok = false;
break;
};
let Some(name) = self.string_at(base, name_offset, "anim.name") else {
ok = false;
break;
};
out.push(AnimDesc {
name,
fps: f32_at(self.data, base + 8),
flags: i32_at(self.data, base + 12).unwrap_or(0),
num_frames: i32_at(self.data, base + 16).unwrap_or(0),
num_movements: i32_at(self.data, base + 20).unwrap_or(0),
movement_index: i32_at(self.data, base + 24).unwrap_or(0),
anim_block: i32_at(self.data, base + 52).unwrap_or(0),
anim_index: i32_at(self.data, base + 56).unwrap_or(0),
});
}
if ok && out.len() == count {
return Ok(out);
}
}
Ok(Vec::new())
}
/// Одна секвенция по смещению. `None` — данные не читаются.
fn parse_sequence(&self, base: usize) -> Option<Sequence> {
let name_offset = i32_at(self.data, base + 4)?;

View file

@ -3202,9 +3202,24 @@ GPU» работает целиком. Пока это не анимация: н
176…208 байт, поэтому перебор шага расширен до 176…320 с обязательной проверкой
имён ВСЕХ секвенций — иначе разбор молча возвращал ноль.
**Порядок работ:** (а) секвенции и включённые модели сделаны; дальше `.ani`
(кадры); (б) привязка вершин сделана; (в) CPU-скиннинг сделан; (г) проигрывание
одной секвенции с циклом; (д) выбор по состоянию и блендинг.
**Сделано (кадры из `.ani`, подготовка).** `mdl.animations()` — описания
анимаций (`mstudioanimdesc_t`), как и секвенции, с определением размера записи по
данным; `anim_block_names()` — имена анимационных файлов; `anim_blocks()` —
границы блоков внутри `.ani`.
**Замеры:** у `m_anm.mdl` — **1563 анимации** (`@reference` 101 кадр,
`@idle_all_01` 280, `@idle_all_02` 151, все 30 fps), 247 блоков, и **все** с
`anim_block > 0`, то есть кадры не в MDL, а в отдельном файле. Файл
`models/m_anm.ani` прочитан целиком: **7,1 МБ**. Значит декодер кадров будет
брать данные оттуда (`mstudioanimblock_t.datastart + animdesc.anim_index`).
**Дальше:** декодирование `mstudioanim_t` (по 6 каналов на кость, дельты с
накоплением, повороты кватернионом), позы костей с родителями, интерполяция
между кадрами, затем выбор секвенции по состоянию.
**Порядок работ:** (а) секвенции, включённые модели и чтение `.ani` сделаны;
(б) привязка вершин сделана; (в) CPU-скиннинг сделан; (г) декодер кадров и
проигрывание секвенции с циклом; (д) выбор по состоянию и блендинг.
**Приёмка:** модель игрока/оружия двигается в кадре, ходьба переходит в бег и
присед по скорости, прыжок показывается отдельной секвенцией.