From 2c7ec501e2fa5c48af0f64e119caeab030005a46 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 17 Oct 2026 11:40:57 +0300 Subject: [PATCH] =?UTF-8?q?A26:=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20.ani=20(?= =?UTF-8?q?=D0=BA=D0=B0=D0=B4=D1=80=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 обновлён. --- crates/engine/src/model.rs | 38 ++++++++++++++ crates/formats/src/mdl.rs | 100 +++++++++++++++++++++++++++++++++++++ docs/PARITY.md | 21 ++++++-- 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/model.rs b/crates/engine/src/model.rs index 695277e..c08a214 100644 --- a/crates/engine/src/model.rs +++ b/crates/engine/src/model.rs @@ -140,6 +140,44 @@ pub fn load_model(vfs: &VirtualFs, name: &str, budget: &mut usize) -> Result { + 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, diff --git a/crates/formats/src/mdl.rs b/crates/formats/src/mdl.rs index 38d6bdc..8624649 100644 --- a/crates/formats/src/mdl.rs +++ b/crates/formats/src/mdl.rs @@ -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 { + 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, 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 { let name_offset = i32_at(self.data, base + 4)?; diff --git a/docs/PARITY.md b/docs/PARITY.md index 436fa8a..16d3dc4 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -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-скиннинг сделан; (г) декодер кадров и +проигрывание секвенции с циклом; (д) выбор по состоянию и блендинг. **Приёмка:** модель игрока/оружия двигается в кадре, ходьба переходит в бег и присед по скорости, прыжок показывается отдельной секвенцией.