diff --git a/crates/msh-core/Cargo.toml b/crates/msh-core/Cargo.toml new file mode 100644 index 0000000..cdea317 --- /dev/null +++ b/crates/msh-core/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "msh-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +nres = { path = "../nres" } diff --git a/crates/msh-core/README.md b/crates/msh-core/README.md new file mode 100644 index 0000000..016df7a --- /dev/null +++ b/crates/msh-core/README.md @@ -0,0 +1,14 @@ +# msh-core + +Парсер core-части формата `MSH`. + +Покрывает: + +- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные); +- `Res4`, `Res5`, `Res10` (опциональные); +- slot lookup по `node/lod/group`. + +Тесты: + +- прогон по всем `.msh` в `testdata`; +- синтетическая минимальная модель. diff --git a/crates/msh-core/src/error.rs b/crates/msh-core/src/error.rs new file mode 100644 index 0000000..81fe54f --- /dev/null +++ b/crates/msh-core/src/error.rs @@ -0,0 +1,74 @@ +use core::fmt; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + MissingResource { + kind: u32, + label: &'static str, + }, + InvalidResourceSize { + label: &'static str, + size: usize, + stride: usize, + }, + InvalidRes2Size { + size: usize, + }, + UnsupportedNodeStride { + stride: usize, + }, + IndexOutOfBounds { + label: &'static str, + index: usize, + limit: usize, + }, + IntegerOverflow, +} + +impl From for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nres(err) => write!(f, "{err}"), + Self::MissingResource { kind, label } => { + write!(f, "missing required resource type={kind} ({label})") + } + Self::InvalidResourceSize { + label, + size, + stride, + } => { + write!( + f, + "invalid {label} size={size}, expected multiple of stride={stride}" + ) + } + Self::InvalidRes2Size { size } => { + write!(f, "invalid Res2 size={size}, expected >= 140") + } + Self::UnsupportedNodeStride { stride } => { + write!( + f, + "unsupported Res1 node stride={stride}, expected 38 or 24" + ) + } + Self::IndexOutOfBounds { + label, + index, + limit, + } => write!( + f, + "{label} index out of bounds: index={index}, limit={limit}" + ), + Self::IntegerOverflow => write!(f, "integer overflow"), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/msh-core/src/lib.rs b/crates/msh-core/src/lib.rs new file mode 100644 index 0000000..84e8a86 --- /dev/null +++ b/crates/msh-core/src/lib.rs @@ -0,0 +1,392 @@ +pub mod error; + +use crate::error::Error; +use std::sync::Arc; + +pub type Result = core::result::Result; + +pub const RES1_NODE_TABLE: u32 = 1; +pub const RES2_SLOTS: u32 = 2; +pub const RES3_POSITIONS: u32 = 3; +pub const RES4_NORMALS: u32 = 4; +pub const RES5_UV0: u32 = 5; +pub const RES6_INDICES: u32 = 6; +pub const RES10_NAMES: u32 = 10; +pub const RES13_BATCHES: u32 = 13; + +#[derive(Clone, Debug)] +pub struct Slot { + pub tri_start: u16, + pub tri_count: u16, + pub batch_start: u16, + pub batch_count: u16, + pub aabb_min: [f32; 3], + pub aabb_max: [f32; 3], + pub sphere_center: [f32; 3], + pub sphere_radius: f32, + pub opaque: [u32; 5], +} + +#[derive(Clone, Debug)] +pub struct Batch { + pub batch_flags: u16, + pub material_index: u16, + pub opaque4: u16, + pub opaque6: u16, + pub index_count: u16, + pub index_start: u32, + pub opaque14: u16, + pub base_vertex: u32, +} + +#[derive(Clone, Debug)] +pub struct Model { + pub node_stride: usize, + pub node_count: usize, + pub nodes_raw: Vec, + pub slots: Vec, + pub positions: Vec<[f32; 3]>, + pub normals: Option>, + pub uv0: Option>, + pub indices: Vec, + pub batches: Vec, + pub node_names: Option>>, +} + +impl Model { + pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option { + if node_index >= self.node_count || lod >= 3 || group >= 5 { + return None; + } + if self.node_stride != 38 { + return None; + } + let node_off = node_index.checked_mul(self.node_stride)?; + let matrix_off = node_off.checked_add(8)?; + let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?; + let raw = read_u16(&self.nodes_raw, word_off).ok()?; + if raw == u16::MAX { + return None; + } + let idx = usize::from(raw); + if idx >= self.slots.len() { + return None; + } + Some(idx) + } +} + +pub fn parse_model_payload(payload: &[u8]) -> Result { + let archive = nres::Archive::open_bytes( + Arc::from(payload.to_vec().into_boxed_slice()), + nres::OpenOptions::default(), + )?; + + let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?; + let res2 = read_required(&archive, RES2_SLOTS, "Res2")?; + let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?; + let res6 = read_required(&archive, RES6_INDICES, "Res6")?; + let res13 = read_required(&archive, RES13_BATCHES, "Res13")?; + + let res4 = read_optional(&archive, RES4_NORMALS)?; + let res5 = read_optional(&archive, RES5_UV0)?; + let res10 = read_optional(&archive, RES10_NAMES)?; + + let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?; + if node_stride != 38 && node_stride != 24 { + return Err(Error::UnsupportedNodeStride { + stride: node_stride, + }); + } + if res1.bytes.len() % node_stride != 0 { + return Err(Error::InvalidResourceSize { + label: "Res1", + size: res1.bytes.len(), + stride: node_stride, + }); + } + let node_count = res1.bytes.len() / node_stride; + + if res2.bytes.len() < 0x8C { + return Err(Error::InvalidRes2Size { + size: res2.bytes.len(), + }); + } + let slot_blob = res2 + .bytes + .len() + .checked_sub(0x8C) + .ok_or(Error::IntegerOverflow)?; + if slot_blob % 68 != 0 { + return Err(Error::InvalidResourceSize { + label: "Res2.slots", + size: slot_blob, + stride: 68, + }); + } + let slot_count = slot_blob / 68; + let mut slots = Vec::with_capacity(slot_count); + for i in 0..slot_count { + let off = 0x8Cusize + .checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?) + .ok_or(Error::IntegerOverflow)?; + slots.push(Slot { + tri_start: read_u16(&res2.bytes, off)?, + tri_count: read_u16(&res2.bytes, off + 2)?, + batch_start: read_u16(&res2.bytes, off + 4)?, + batch_count: read_u16(&res2.bytes, off + 6)?, + aabb_min: [ + read_f32(&res2.bytes, off + 8)?, + read_f32(&res2.bytes, off + 12)?, + read_f32(&res2.bytes, off + 16)?, + ], + aabb_max: [ + read_f32(&res2.bytes, off + 20)?, + read_f32(&res2.bytes, off + 24)?, + read_f32(&res2.bytes, off + 28)?, + ], + sphere_center: [ + read_f32(&res2.bytes, off + 32)?, + read_f32(&res2.bytes, off + 36)?, + read_f32(&res2.bytes, off + 40)?, + ], + sphere_radius: read_f32(&res2.bytes, off + 44)?, + opaque: [ + read_u32(&res2.bytes, off + 48)?, + read_u32(&res2.bytes, off + 52)?, + read_u32(&res2.bytes, off + 56)?, + read_u32(&res2.bytes, off + 60)?, + read_u32(&res2.bytes, off + 64)?, + ], + }); + } + + let positions = parse_positions(&res3.bytes)?; + let indices = parse_u16_array(&res6.bytes, "Res6")?; + let batches = parse_batches(&res13.bytes)?; + + let normals = match res4 { + Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?), + None => None, + }; + let uv0 = match res5 { + Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?), + None => None, + }; + let node_names = match res10 { + Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?), + None => None, + }; + + Ok(Model { + node_stride, + node_count, + nodes_raw: res1.bytes, + slots, + positions, + normals, + uv0, + indices, + batches, + node_names, + }) +} + +fn parse_positions(data: &[u8]) -> Result> { + if !data.len().is_multiple_of(12) { + return Err(Error::InvalidResourceSize { + label: "Res3", + size: data.len(), + stride: 12, + }); + } + let count = data.len() / 12; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let off = i * 12; + out.push([ + read_f32(data, off)?, + read_f32(data, off + 4)?, + read_f32(data, off + 8)?, + ]); + } + Ok(out) +} + +fn parse_batches(data: &[u8]) -> Result> { + if !data.len().is_multiple_of(20) { + return Err(Error::InvalidResourceSize { + label: "Res13", + size: data.len(), + stride: 20, + }); + } + let count = data.len() / 20; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let off = i * 20; + out.push(Batch { + batch_flags: read_u16(data, off)?, + material_index: read_u16(data, off + 2)?, + opaque4: read_u16(data, off + 4)?, + opaque6: read_u16(data, off + 6)?, + index_count: read_u16(data, off + 8)?, + index_start: read_u32(data, off + 10)?, + opaque14: read_u16(data, off + 14)?, + base_vertex: read_u32(data, off + 16)?, + }); + } + Ok(out) +} + +fn parse_u16_array(data: &[u8], label: &'static str) -> Result> { + if !data.len().is_multiple_of(2) { + return Err(Error::InvalidResourceSize { + label, + size: data.len(), + stride: 2, + }); + } + let mut out = Vec::with_capacity(data.len() / 2); + for i in (0..data.len()).step_by(2) { + out.push(read_u16(data, i)?); + } + Ok(out) +} + +fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result> { + if !data.len().is_multiple_of(4) { + return Err(Error::InvalidResourceSize { + label, + size: data.len(), + stride: 4, + }); + } + let mut out = Vec::with_capacity(data.len() / 4); + for i in (0..data.len()).step_by(4) { + out.push([ + read_i8(data, i)?, + read_i8(data, i + 1)?, + read_i8(data, i + 2)?, + read_i8(data, i + 3)?, + ]); + } + Ok(out) +} + +fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result> { + if !data.len().is_multiple_of(4) { + return Err(Error::InvalidResourceSize { + label, + size: data.len(), + stride: 4, + }); + } + let mut out = Vec::with_capacity(data.len() / 4); + for i in (0..data.len()).step_by(4) { + out.push([read_i16(data, i)?, read_i16(data, i + 2)?]); + } + Ok(out) +} + +fn parse_res10_names(data: &[u8], node_count: usize) -> Result>> { + let mut out = Vec::with_capacity(node_count); + let mut off = 0usize; + for _ in 0..node_count { + let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?; + off = off.checked_add(4).ok_or(Error::IntegerOverflow)?; + if len == 0 { + out.push(None); + continue; + } + let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?; + let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?; + let slice = data.get(off..end).ok_or(Error::InvalidResourceSize { + label: "Res10", + size: data.len(), + stride: 1, + })?; + let text = if slice.last().copied() == Some(0) { + &slice[..slice.len().saturating_sub(1)] + } else { + slice + }; + let decoded = String::from_utf8_lossy(text).to_string(); + out.push(Some(decoded)); + off = end; + } + Ok(out) +} + +struct RawResource { + meta: nres::EntryMeta, + bytes: Vec, +} + +fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result { + let id = archive + .entries() + .find(|entry| entry.meta.kind == kind) + .map(|entry| entry.id) + .ok_or(Error::MissingResource { kind, label })?; + let entry = archive.get(id).ok_or(Error::IndexOutOfBounds { + label, + index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?, + limit: archive.entry_count(), + })?; + let data = archive.read(id)?.into_owned(); + Ok(RawResource { + meta: entry.meta.clone(), + bytes: data, + }) +} + +fn read_optional(archive: &nres::Archive, kind: u32) -> Result> { + let Some(id) = archive + .entries() + .find(|entry| entry.meta.kind == kind) + .map(|entry| entry.id) + else { + return Ok(None); + }; + let entry = archive.get(id).ok_or(Error::IndexOutOfBounds { + label: "optional", + index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?, + limit: archive.entry_count(), + })?; + let data = archive.read(id)?.into_owned(); + Ok(Some(RawResource { + meta: entry.meta.clone(), + bytes: data, + })) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u16::from_le_bytes(arr)) +} + +fn read_i16(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(i16::from_le_bytes(arr)) +} + +fn read_i8(data: &[u8], offset: usize) -> Result { + let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?; + Ok(i8::from_le_bytes([byte])) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_f32(data: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32(data, offset)?)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/msh-core/src/tests.rs b/crates/msh-core/src/tests.rs new file mode 100644 index 0000000..1eefb31 --- /dev/null +++ b/crates/msh-core/src/tests.rs @@ -0,0 +1,296 @@ +use super::*; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +fn is_msh_name(name: &str) -> bool { + name.to_ascii_lowercase().ends_with(".msh") +} + +#[test] +fn parse_all_game_msh_models() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata"); + return; + } + + let mut model_count = 0usize; + let mut renderable_count = 0usize; + let mut legacy_stride24_count = 0usize; + + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + + for entry in archive.entries() { + if !is_msh_name(&entry.meta.name) { + continue; + } + model_count += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read model '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse model '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + + if model.node_stride == 24 { + legacy_stride24_count += 1; + } + + for node_index in 0..model.node_count { + for lod in 0..3 { + for group in 0..5 { + if let Some(slot_idx) = model.slot_index(node_index, lod, group) { + assert!( + slot_idx < model.slots.len(), + "slot index out of bounds in '{}' ({})", + entry.meta.name, + archive_path.display() + ); + } + } + } + } + + let mut has_renderable_batch = false; + for node_index in 0..model.node_count { + let Some(slot_idx) = model.slot_index(node_index, 0, 0) else { + continue; + }; + let slot = &model.slots[slot_idx]; + let batch_end = + usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count)); + if batch_end > model.batches.len() { + continue; + } + for batch in &model.batches[usize::from(slot.batch_start)..batch_end] { + let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX); + let index_count = usize::from(batch.index_count); + let end = index_start.saturating_add(index_count); + if end <= model.indices.len() && index_count >= 3 { + has_renderable_batch = true; + break; + } + } + if has_renderable_batch { + break; + } + } + if has_renderable_batch { + renderable_count += 1; + } + } + } + + assert!(model_count > 0, "no .msh entries found"); + assert!( + renderable_count > 0, + "no renderable models (lod0/group0) were detected" + ); + assert!( + legacy_stride24_count <= model_count, + "internal test accounting error" + ); +} + +#[test] +fn parse_minimal_synthetic_model() { + // Nested NRes with required resources only. + let mut payload = Vec::new(); + payload.extend_from_slice(b"NRes"); + payload.extend_from_slice(&0x100u32.to_le_bytes()); + payload.extend_from_slice(&5u32.to_le_bytes()); // entry_count + payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder + + let mut resource_offsets = Vec::new(); + let mut resource_sizes = Vec::new(); + let mut resource_types = Vec::new(); + let mut resource_attr3 = Vec::new(); + let mut resource_names = Vec::new(); + + let add_resource = |payload: &mut Vec, + offsets: &mut Vec, + sizes: &mut Vec, + types: &mut Vec, + attr3: &mut Vec, + names: &mut Vec, + kind: u32, + name: &str, + data: &[u8], + attr3_val: u32| { + offsets.push(u32::try_from(payload.len()).expect("offset overflow")); + payload.extend_from_slice(data); + while !payload.len().is_multiple_of(8) { + payload.push(0); + } + sizes.push(u32::try_from(data.len()).expect("size overflow")); + types.push(kind); + attr3.push(attr3_val); + names.push(name.to_string()); + }; + + let node = { + let mut b = vec![0u8; 38]; + // slot[0][0] = 0 + b[8..10].copy_from_slice(&0u16.to_le_bytes()); + for i in 1..15 { + let off = 8 + i * 2; + b[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes()); + } + b + }; + let mut res2 = vec![0u8; 0x8C + 68]; + res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start + res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count + res2[0x8C + 4..0x8C + 6].copy_from_slice(&0u16.to_le_bytes()); // batch_start + res2[0x8C + 6..0x8C + 8].copy_from_slice(&1u16.to_le_bytes()); // batch_count + let positions = [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(); + let indices = [0u16, 1, 2] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(); + let batch = { + let mut b = vec![0u8; 20]; + b[0..2].copy_from_slice(&0u16.to_le_bytes()); + b[2..4].copy_from_slice(&0u16.to_le_bytes()); + b[8..10].copy_from_slice(&3u16.to_le_bytes()); // index_count + b[10..14].copy_from_slice(&0u32.to_le_bytes()); // index_start + b[16..20].copy_from_slice(&0u32.to_le_bytes()); // base_vertex + b + }; + + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES1_NODE_TABLE, + "Res1", + &node, + 38, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES2_SLOTS, + "Res2", + &res2, + 68, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES3_POSITIONS, + "Res3", + &positions, + 12, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES6_INDICES, + "Res6", + &indices, + 2, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES13_BATCHES, + "Res13", + &batch, + 20, + ); + + let directory_offset = payload.len(); + for i in 0..resource_types.len() { + payload.extend_from_slice(&resource_types[i].to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // attr1 + payload.extend_from_slice(&0u32.to_le_bytes()); // attr2 + payload.extend_from_slice(&resource_sizes[i].to_le_bytes()); + payload.extend_from_slice(&resource_attr3[i].to_le_bytes()); + let mut name_raw = [0u8; 36]; + let bytes = resource_names[i].as_bytes(); + name_raw[..bytes.len()].copy_from_slice(bytes); + payload.extend_from_slice(&name_raw); + payload.extend_from_slice(&resource_offsets[i].to_le_bytes()); + payload.extend_from_slice(&(i as u32).to_le_bytes()); // sort index + } + let total_size = u32::try_from(payload.len()).expect("size overflow"); + payload[12..16].copy_from_slice(&total_size.to_le_bytes()); + assert_eq!( + directory_offset + resource_types.len() * 64, + payload.len(), + "synthetic nested NRes layout invalid" + ); + + let model = parse_model_payload(&payload).expect("failed to parse synthetic model"); + assert_eq!(model.node_count, 1); + assert_eq!(model.positions.len(), 3); + assert_eq!(model.indices.len(), 3); + assert_eq!(model.batches.len(), 1); + assert_eq!(model.slot_index(0, 0, 0), Some(0)); +} diff --git a/crates/render-core/Cargo.toml b/crates/render-core/Cargo.toml new file mode 100644 index 0000000..4bdaa9e --- /dev/null +++ b/crates/render-core/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "render-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +msh-core = { path = "../msh-core" } +nres = { path = "../nres" } diff --git a/crates/render-core/README.md b/crates/render-core/README.md new file mode 100644 index 0000000..1b58aec --- /dev/null +++ b/crates/render-core/README.md @@ -0,0 +1,14 @@ +# render-core + +CPU-подготовка draw-данных для моделей `MSH`. + +Покрывает: + +- обход `node -> slot -> batch`; +- раскрытие индексов в triangle-list (`Vec<[f32;3]>`); +- расчёт bounds по вершинам. + +Тесты: + +- построение рендер-сеток на реальных `.msh` из `testdata`; +- unit-test bounds. diff --git a/crates/render-core/src/lib.rs b/crates/render-core/src/lib.rs new file mode 100644 index 0000000..8e0b5e8 --- /dev/null +++ b/crates/render-core/src/lib.rs @@ -0,0 +1,84 @@ +use msh_core::Model; + +#[derive(Clone, Debug)] +pub struct RenderMesh { + pub vertices: Vec<[f32; 3]>, + pub batch_count: usize, +} + +impl RenderMesh { + pub fn triangle_count(&self) -> usize { + self.vertices.len() / 3 + } +} + +/// Builds an expanded triangle list for a specific LOD/group pair. +/// +/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths. +pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh { + let mut vertices = Vec::new(); + let mut batch_count = 0usize; + + for node_index in 0..model.node_count { + let Some(slot_idx) = model.slot_index(node_index, lod, group) else { + continue; + }; + let Some(slot) = model.slots.get(slot_idx) else { + continue; + }; + let batch_start = usize::from(slot.batch_start); + let batch_end = batch_start.saturating_add(usize::from(slot.batch_count)); + if batch_end > model.batches.len() { + continue; + } + + for batch in &model.batches[batch_start..batch_end] { + let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX); + let index_count = usize::from(batch.index_count); + let index_end = index_start.saturating_add(index_count); + if index_end > model.indices.len() || index_count < 3 { + continue; + } + + for &idx in &model.indices[index_start..index_end] { + let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx)); + let Ok(final_idx) = usize::try_from(final_idx_u64) else { + continue; + }; + let Some(pos) = model.positions.get(final_idx) else { + continue; + }; + vertices.push(*pos); + } + batch_count += 1; + } + } + + RenderMesh { + vertices, + batch_count, + } +} + +pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> { + let mut iter = vertices.iter(); + let first = iter.next()?; + let mut min_v = *first; + let mut max_v = *first; + + for v in iter { + for i in 0..3 { + if v[i] < min_v[i] { + min_v[i] = v[i]; + } + if v[i] > max_v[i] { + max_v[i] = v[i]; + } + } + } + + Some((min_v, max_v)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render-core/src/tests.rs b/crates/render-core/src/tests.rs new file mode 100644 index 0000000..9c5eb5d --- /dev/null +++ b/crates/render-core/src/tests.rs @@ -0,0 +1,101 @@ +use super::*; +use msh_core::parse_model_payload; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +#[test] +fn build_render_mesh_for_real_models() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata"); + return; + } + + let mut models_checked = 0usize; + let mut meshes_non_empty = 0usize; + let mut bounds_non_empty = 0usize; + + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + for entry in archive.entries() { + if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") { + continue; + } + models_checked += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read model '{}' from {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse model '{}' from {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let mesh = build_render_mesh(&model, 0, 0); + if !mesh.vertices.is_empty() { + meshes_non_empty += 1; + } + if compute_bounds(&mesh.vertices).is_some() { + bounds_non_empty += 1; + } + } + } + + assert!(models_checked > 0, "no MSH models found"); + assert!( + meshes_non_empty > 0, + "all generated render meshes are empty" + ); + assert_eq!( + meshes_non_empty, bounds_non_empty, + "bounds must be available for every non-empty mesh" + ); +} + +#[test] +fn compute_bounds_handles_empty_and_non_empty() { + assert!(compute_bounds(&[]).is_none()); + let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]]) + .expect("bounds expected"); + assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); + assert_eq!(bounds.1, [1.0, 5.0, 9.0]); +} diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml new file mode 100644 index 0000000..376a25e --- /dev/null +++ b/crates/render-demo/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "render-demo" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +demo = ["dep:sdl2", "dep:glow"] + +[dependencies] +msh-core = { path = "../msh-core" } +nres = { path = "../nres" } +render-core = { path = "../render-core" } +sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } +glow = { version = "0.16", optional = true } + +[[bin]] +name = "parkan-render-demo" +path = "src/main.rs" +required-features = ["demo"] diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md new file mode 100644 index 0000000..b33b18c --- /dev/null +++ b/crates/render-demo/README.md @@ -0,0 +1,30 @@ +# render-demo + +Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL ES 2.0`). + +## Назначение + +- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах. +- Служить минимальным reference-приложением. + +## Запуск + +```bash +cargo run -p render-demo --features demo -- \ + --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ + --model "A_L_01.msh" \ + --lod 0 \ + --group 0 +``` + +Параметры: + +- `--archive` (обязательный): NRes-архив с `.msh` entry. +- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`. +- `--lod` (опционально, default `0`). +- `--group` (опционально, default `0`). + +## Ограничения + +- Рендер только геометрии (без материалов/текстур/FX). +- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list. diff --git a/crates/render-demo/build.rs b/crates/render-demo/build.rs new file mode 100644 index 0000000..126d1d7 --- /dev/null +++ b/crates/render-demo/build.rs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(windows)] + println!("cargo:rustc-link-lib=advapi32"); +} diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs new file mode 100644 index 0000000..4c73c09 --- /dev/null +++ b/crates/render-demo/src/lib.rs @@ -0,0 +1,113 @@ +use msh_core::{parse_model_payload, Model}; +use nres::Archive; +use std::path::Path; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + Msh(msh_core::error::Error), + NoMshEntries, + ModelNotFound(String), +} + +impl From for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +impl From for Error { + fn from(value: msh_core::error::Error) -> Self { + Self::Msh(value) + } +} + +pub type Result = core::result::Result; + +pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { + let archive = Archive::open_path(path)?; + let mut msh_entries = Vec::new(); + for entry in archive.entries() { + if entry.meta.name.to_ascii_lowercase().ends_with(".msh") { + msh_entries.push((entry.id, entry.meta.name.clone())); + } + } + if msh_entries.is_empty() { + return Err(Error::NoMshEntries); + } + + let target_id = if let Some(name) = model_name { + msh_entries + .iter() + .find(|(_, n)| n.eq_ignore_ascii_case(name)) + .map(|(id, _)| *id) + .ok_or_else(|| Error::ModelNotFound(name.to_string()))? + } else { + msh_entries[0].0 + }; + + let payload = archive.read(target_id)?; + Ok(parse_model_payload(payload.as_slice())?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + fn collect_files_recursive(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } + } + + fn archive_with_msh() -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + for path in files { + let Ok(bytes) = fs::read(&path) else { + continue; + }; + if bytes.get(0..4) != Some(b"NRes") { + continue; + } + let Ok(archive) = Archive::open_path(&path) else { + continue; + }; + if archive + .entries() + .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh")) + { + return Some(path); + } + } + None + } + + #[test] + fn load_model_from_real_archive() { + let Some(path) = archive_with_msh() else { + eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata"); + return; + }; + let model = load_model_from_archive(&path, None) + .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display())); + assert!(model.node_count > 0); + assert!(!model.positions.is_empty()); + assert!(!model.indices.is_empty()); + } +} diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs new file mode 100644 index 0000000..c991c80 --- /dev/null +++ b/crates/render-demo/src/main.rs @@ -0,0 +1,357 @@ +use glow::HasContext as _; +use render_core::{build_render_mesh, compute_bounds}; +use render_demo::load_model_from_archive; +use std::path::PathBuf; +use std::time::Instant; + +struct Args { + archive: PathBuf, + model: Option, + lod: usize, + group: usize, +} + +fn parse_args() -> Result { + let mut archive = None; + let mut model = None; + let mut lod = 0usize; + let mut group = 0usize; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--archive" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --archive"))?; + archive = Some(PathBuf::from(value)); + } + "--model" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --model"))?; + model = Some(value); + } + "--lod" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --lod"))?; + lod = value + .parse::() + .map_err(|_| String::from("invalid --lod value"))?; + } + "--group" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --group"))?; + group = value + .parse::() + .map_err(|_| String::from("invalid --group value"))?; + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + let archive = archive.ok_or_else(|| String::from("missing required --archive"))?; + Ok(Args { + archive, + model, + lod, + group, + }) +} + +fn print_help() { + eprintln!("parkan-render-demo --archive [--model ] [--lod N] [--group N]"); +} + +fn main() { + let args = match parse_args() { + Ok(v) => v, + Err(err) => { + eprintln!("{err}"); + print_help(); + std::process::exit(2); + } + }; + + let model = match load_model_from_archive(&args.archive, args.model.as_deref()) { + Ok(v) => v, + Err(err) => { + eprintln!("failed to load model: {err:?}"); + std::process::exit(1); + } + }; + + let mesh = build_render_mesh(&model, args.lod, args.group); + if mesh.vertices.is_empty() { + eprintln!( + "model has no renderable triangles for lod={} group={}", + args.lod, args.group + ); + std::process::exit(1); + } + let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else { + eprintln!("failed to compute mesh bounds"); + std::process::exit(1); + }; + + let center = [ + 0.5 * (bounds_min[0] + bounds_max[0]), + 0.5 * (bounds_min[1] + bounds_max[1]), + 0.5 * (bounds_min[2] + bounds_max[2]), + ]; + let extent = [ + bounds_max[0] - bounds_min[0], + bounds_max[1] - bounds_min[1], + bounds_max[2] - bounds_min[2], + ]; + let radius = + (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5; + let camera_distance = (radius * 2.5).max(2.0); + + let sdl = sdl2::init().expect("failed to init SDL2"); + let video = sdl.video().expect("failed to init SDL2 video"); + + { + let gl_attr = video.gl_attr(); + gl_attr.set_context_profile(sdl2::video::GLProfile::GLES); + gl_attr.set_context_version(2, 0); + gl_attr.set_depth_size(24); + gl_attr.set_double_buffer(true); + } + + let window = video + .window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720) + .opengl() + .resizable() + .build() + .expect("failed to create window"); + + let gl_ctx = window + .gl_create_context() + .expect("failed to create OpenGL context"); + window + .gl_make_current(&gl_ctx) + .expect("failed to make GL context current"); + let _ = video.gl_set_swap_interval(1); + + let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3); + for pos in &mesh.vertices { + vertices_flat.extend_from_slice(pos); + } + + let gl = unsafe { + glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) + }; + + let program = unsafe { create_program(&gl).expect("failed to create shader program") }; + let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; + let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }; + let a_pos = a_pos.expect("shader attribute a_pos is missing"); + + let vbo = unsafe { gl.create_buffer().expect("failed to create VBO") }; + unsafe { + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + cast_slice_u8(&vertices_flat), + glow::STATIC_DRAW, + ); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + } + + let mut events = sdl.event_pump().expect("failed to get SDL event pump"); + let start = Instant::now(); + + 'main_loop: loop { + for event in events.poll_iter() { + match event { + sdl2::event::Event::Quit { .. } => break 'main_loop, + sdl2::event::Event::KeyDown { + keycode: Some(sdl2::keyboard::Keycode::Escape), + .. + } => break 'main_loop, + _ => {} + } + } + + let elapsed = start.elapsed().as_secs_f32(); + let (w, h) = window.size(); + let aspect = (w as f32 / (h.max(1) as f32)).max(0.01); + + let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0); + let view = mat4_translation(0.0, 0.0, -camera_distance); + let center_shift = mat4_translation(-center[0], -center[1], -center[2]); + let rot = mat4_rotation_y(elapsed * 0.35); + let model_m = mat4_mul(&rot, ¢er_shift); + let vp = mat4_mul(&view, &model_m); + let mvp = mat4_mul(&proj, &vp); + + unsafe { + gl.viewport(0, 0, w as i32, h as i32); + gl.enable(glow::DEPTH_TEST); + gl.clear_color(0.06, 0.08, 0.12, 1.0); + gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); + + gl.use_program(Some(program)); + gl.uniform_matrix_4_f32_slice(u_mvp.as_ref(), false, &mvp); + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.enable_vertex_attrib_array(a_pos); + gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 12, 0); + gl.draw_arrays( + glow::TRIANGLES, + 0, + i32::try_from(mesh.vertices.len()).unwrap_or(i32::MAX), + ); + gl.disable_vertex_attrib_array(a_pos); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + gl.use_program(None); + } + + window.gl_swap_window(); + } + + unsafe { + gl.delete_buffer(vbo); + gl.delete_program(program); + } +} + +unsafe fn create_program(gl: &glow::Context) -> Result { + let vs_src = r#" +attribute vec3 a_pos; +uniform mat4 u_mvp; +void main() { + gl_Position = u_mvp * vec4(a_pos, 1.0); +} +"#; + + let fs_src = r#" +precision mediump float; +void main() { + gl_FragColor = vec4(0.85, 0.90, 1.00, 1.0); +} +"#; + + let program = gl.create_program().map_err(|e| e.to_string())?; + let vs = gl + .create_shader(glow::VERTEX_SHADER) + .map_err(|e| e.to_string())?; + let fs = gl + .create_shader(glow::FRAGMENT_SHADER) + .map_err(|e| e.to_string())?; + + gl.shader_source(vs, vs_src); + gl.compile_shader(vs); + if !gl.get_shader_compile_status(vs) { + let log = gl.get_shader_info_log(vs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("vertex shader compile failed: {log}")); + } + + gl.shader_source(fs, fs_src); + gl.compile_shader(fs); + if !gl.get_shader_compile_status(fs) { + let log = gl.get_shader_info_log(fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("fragment shader compile failed: {log}")); + } + + gl.attach_shader(program, vs); + gl.attach_shader(program, fs); + gl.link_program(program); + + gl.detach_shader(program, vs); + gl.detach_shader(program, fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + gl.delete_program(program); + return Err(format!("program link failed: {log}")); + } + + Ok(program) +} + +fn cast_slice_u8(slice: &[T]) -> &[u8] { + unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, std::mem::size_of_val(slice)) } +} + +fn mat4_identity() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] { + let mut m = mat4_identity(); + m[12] = x; + m[13] = y; + m[14] = z; + m +} + +fn mat4_rotation_y(rad: f32) -> [f32; 16] { + let c = rad.cos(); + let s = rad.sin(); + [ + c, 0.0, -s, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + s, 0.0, c, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] { + let f = 1.0 / (0.5 * fovy).tan(); + let nf = 1.0 / (near - far); + [ + f / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + f, + 0.0, + 0.0, + 0.0, + 0.0, + (far + near) * nf, + -1.0, + 0.0, + 0.0, + (2.0 * far * near) * nf, + 0.0, + ] +} + +fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + let mut out = [0.0f32; 16]; + for c in 0..4 { + for r in 0..4 { + let mut acc = 0.0f32; + for k in 0..4 { + acc += a[k * 4 + r] * b[c * 4 + k]; + } + out[c * 4 + r] = acc; + } + } + out +} diff --git a/crates/texm/Cargo.toml b/crates/texm/Cargo.toml new file mode 100644 index 0000000..7085293 --- /dev/null +++ b/crates/texm/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "texm" +version = "0.1.0" +edition = "2021" + +[dependencies] +nres = { path = "../nres" } diff --git a/crates/texm/README.md b/crates/texm/README.md new file mode 100644 index 0000000..370ac54 --- /dev/null +++ b/crates/texm/README.md @@ -0,0 +1,15 @@ +# texm + +Парсер формата текстур `Texm`. + +Покрывает: + +- header (`width/height/mipCount/flags/format`); +- core size расчёт; +- optional `Page` chunk; +- строгую валидацию layout. + +Тесты: + +- прогон по реальным `Texm` из `testdata`; +- синтетические edge-cases (indexed + page, minimal rgba). diff --git a/crates/texm/src/error.rs b/crates/texm/src/error.rs new file mode 100644 index 0000000..a5dda77 --- /dev/null +++ b/crates/texm/src/error.rs @@ -0,0 +1,61 @@ +use core::fmt; + +#[derive(Debug)] +pub enum Error { + HeaderTooSmall { + size: usize, + }, + InvalidMagic { + got: u32, + }, + InvalidDimensions { + width: u32, + height: u32, + }, + InvalidMipCount { + mip_count: u32, + }, + UnknownFormat { + format: u32, + }, + IntegerOverflow, + CoreDataOutOfBounds { + expected_end: usize, + actual_size: usize, + }, + InvalidPageMagic, + InvalidPageSize { + expected: usize, + actual: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HeaderTooSmall { size } => { + write!(f, "Texm payload too small for header: {size}") + } + Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"), + Self::InvalidDimensions { width, height } => { + write!(f, "invalid Texm dimensions: {width}x{height}") + } + Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"), + Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"), + Self::IntegerOverflow => write!(f, "integer overflow"), + Self::CoreDataOutOfBounds { + expected_end, + actual_size, + } => write!( + f, + "Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}" + ), + Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"), + Self::InvalidPageSize { expected, actual } => { + write!(f, "invalid Page chunk size: expected={expected}, actual={actual}") + } + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs new file mode 100644 index 0000000..c3616d5 --- /dev/null +++ b/crates/texm/src/lib.rs @@ -0,0 +1,258 @@ +pub mod error; + +use crate::error::Error; + +pub type Result = core::result::Result; + +pub const TEXM_MAGIC: u32 = 0x6D78_6554; +pub const PAGE_MAGIC: u32 = 0x6567_6150; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PixelFormat { + Indexed8, + Rgb565, + Rgb556, + Argb4444, + LuminanceAlpha88, + Rgb888, + Argb8888, +} + +impl PixelFormat { + pub fn from_raw(raw: u32) -> Option { + match raw { + 0 => Some(Self::Indexed8), + 565 => Some(Self::Rgb565), + 556 => Some(Self::Rgb556), + 4444 => Some(Self::Argb4444), + 88 => Some(Self::LuminanceAlpha88), + 888 => Some(Self::Rgb888), + 8888 => Some(Self::Argb8888), + _ => None, + } + } + + pub fn bytes_per_pixel(self) -> usize { + match self { + Self::Indexed8 => 1, + Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2, + Self::Rgb888 | Self::Argb8888 => 4, + } + } +} + +#[derive(Clone, Debug)] +pub struct Header { + pub width: u32, + pub height: u32, + pub mip_count: u32, + pub flags4: u32, + pub flags5: u32, + pub unk6: u32, + pub format_raw: u32, + pub format: PixelFormat, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct MipLevel { + pub width: u32, + pub height: u32, + pub offset: usize, + pub size: usize, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct PageRect { + pub x: i16, + pub w: i16, + pub y: i16, + pub h: i16, +} + +#[derive(Clone, Debug)] +pub struct Texture { + pub header: Header, + pub palette: Option<[u8; 1024]>, + pub mip_levels: Vec, + pub page_rects: Vec, +} + +impl Texture { + pub fn core_size(&self) -> usize { + let mut size = 32usize; + if self.palette.is_some() { + size += 1024; + } + for level in &self.mip_levels { + size += level.size; + } + size + } +} + +pub fn parse_texm(payload: &[u8]) -> Result { + if payload.len() < 32 { + return Err(Error::HeaderTooSmall { + size: payload.len(), + }); + } + + let magic = read_u32(payload, 0)?; + if magic != TEXM_MAGIC { + return Err(Error::InvalidMagic { got: magic }); + } + + let width = read_u32(payload, 4)?; + let height = read_u32(payload, 8)?; + let mip_count = read_u32(payload, 12)?; + let flags4 = read_u32(payload, 16)?; + let flags5 = read_u32(payload, 20)?; + let unk6 = read_u32(payload, 24)?; + let format_raw = read_u32(payload, 28)?; + + if width == 0 || height == 0 { + return Err(Error::InvalidDimensions { width, height }); + } + if mip_count == 0 { + return Err(Error::InvalidMipCount { mip_count }); + } + + let format = + PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?; + let bytes_per_pixel = format.bytes_per_pixel(); + + let mut offset = 32usize; + let palette = if format == PixelFormat::Indexed8 { + let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?; + if end > payload.len() { + return Err(Error::CoreDataOutOfBounds { + expected_end: end, + actual_size: payload.len(), + }); + } + let mut pal = [0u8; 1024]; + pal.copy_from_slice(&payload[offset..end]); + offset = end; + Some(pal) + } else { + None + }; + + let mut mip_levels = + Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?); + let mut w = width; + let mut h = height; + for _ in 0..mip_count { + let pixel_count_u64 = u64::from(w) + .checked_mul(u64::from(h)) + .ok_or(Error::IntegerOverflow)?; + let level_size_u64 = pixel_count_u64 + .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?) + .ok_or(Error::IntegerOverflow)?; + let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?; + let level_offset = offset; + offset = offset + .checked_add(level_size) + .ok_or(Error::IntegerOverflow)?; + if offset > payload.len() { + return Err(Error::CoreDataOutOfBounds { + expected_end: offset, + actual_size: payload.len(), + }); + } + mip_levels.push(MipLevel { + width: w, + height: h, + offset: level_offset, + size: level_size, + }); + w = w.max(1) >> 1; + h = h.max(1) >> 1; + if w == 0 { + w = 1; + } + if h == 0 { + h = 1; + } + } + + let page_rects = parse_page_tail(payload, offset)?; + + Ok(Texture { + header: Header { + width, + height, + mip_count, + flags4, + flags5, + unk6, + format_raw, + format, + }, + palette, + mip_levels, + page_rects, + }) +} + +fn parse_page_tail(payload: &[u8], core_end: usize) -> Result> { + if core_end == payload.len() { + return Ok(Vec::new()); + } + if payload.len().saturating_sub(core_end) < 8 { + return Err(Error::InvalidPageSize { + expected: 8, + actual: payload.len().saturating_sub(core_end), + }); + } + let magic = read_u32(payload, core_end)?; + if magic != PAGE_MAGIC { + return Err(Error::InvalidPageMagic); + } + let rect_count = read_u32(payload, core_end + 4)?; + let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?; + let expected_size = 8usize + .checked_add( + rect_count_usize + .checked_mul(8) + .ok_or(Error::IntegerOverflow)?, + ) + .ok_or(Error::IntegerOverflow)?; + let actual = payload.len().saturating_sub(core_end); + if expected_size != actual { + return Err(Error::InvalidPageSize { + expected: expected_size, + actual, + }); + } + + let mut rects = Vec::with_capacity(rect_count_usize); + for i in 0..rect_count_usize { + let off = core_end + .checked_add(8) + .and_then(|v| v.checked_add(i * 8)) + .ok_or(Error::IntegerOverflow)?; + rects.push(PageRect { + x: read_i16(payload, off)?, + w: read_i16(payload, off + 2)?, + y: read_i16(payload, off + 4)?, + h: read_i16(payload, off + 6)?, + }); + } + Ok(rects) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_i16(data: &[u8], offset: usize) -> Result { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(i16::from_le_bytes(arr)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs new file mode 100644 index 0000000..d021346 --- /dev/null +++ b/crates/texm/src/tests.rs @@ -0,0 +1,150 @@ +use super::*; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +#[test] +fn texm_parse_all_game_textures() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata"); + return; + } + + let mut texm_total = 0usize; + let mut texm_with_page = 0usize; + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + + for entry in archive.entries() { + if entry.meta.kind != TEXM_MAGIC { + continue; + } + texm_total += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read Texm entry '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse Texm '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + if !texture.page_rects.is_empty() { + texm_with_page += 1; + } + + assert!( + texture.core_size() <= payload.as_slice().len(), + "core size must be within payload for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + assert_eq!( + usize::try_from(texture.header.mip_count).ok(), + Some(texture.mip_levels.len()), + "mip count mismatch for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + } + } + + assert!(texm_total > 0, "no Texm textures found"); + assert!( + texm_with_page > 0, + "expected at least one Texm texture with Page chunk" + ); +} + +#[test] +fn texm_parse_minimal_argb8888_no_page() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // width + payload.extend_from_slice(&1u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&8888u32.to_le_bytes()); // format + payload.extend_from_slice(&[1, 2, 3, 4]); // one pixel + + let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); + assert_eq!(parsed.header.width, 1); + assert_eq!(parsed.header.height, 1); + assert_eq!(parsed.mip_levels.len(), 1); + assert!(parsed.page_rects.is_empty()); +} + +#[test] +fn texm_parse_indexed_with_page_chunk() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&2u32.to_le_bytes()); // width + payload.extend_from_slice(&2u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8 + payload.extend_from_slice(&[0u8; 1024]); // palette + payload.extend_from_slice(&[1, 2, 3, 4]); // pixels + payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count + payload.extend_from_slice(&0i16.to_le_bytes()); // x + payload.extend_from_slice(&2i16.to_le_bytes()); // w + payload.extend_from_slice(&0i16.to_le_bytes()); // y + payload.extend_from_slice(&2i16.to_le_bytes()); // h + + let parsed = parse_texm(&payload).expect("failed to parse indexed texm"); + assert!(parsed.palette.is_some()); + assert_eq!(parsed.page_rects.len(), 1); + assert_eq!( + parsed.page_rects[0], + PageRect { + x: 0, + w: 2, + y: 0, + h: 2 + } + ); +} diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md index 7dd1d4b..22d02d8 100644 --- a/docs/specs/fxid.md +++ b/docs/specs/fxid.md @@ -1,89 +1,20 @@ # FXID -Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для: - -- 1:1 загрузки и исполнения в совместимом runtime; -- построения валидатора payload; -- создания lossless-конвертера (`binary -> IR -> binary`); -- создания редактора с безопасным редактированием полей. +`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy. +Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов. Связанный контейнер: [NRes / RsLi](nres.md). ---- +## 1. Контейнер -## 1. Источники и статус восстановления +- Тип ресурса в `NRes`: `0x44495846` (`FXID`). +- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть. -Спецификация восстановлена по: - -- `tmp/disassembler1/Effect.dll.c`; -- `tmp/disassembler2/Effect.dll.asm`; -- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`; -- проверке реальных архивов `testdata/nres`. - -Ключевые функции: - -- parser FXID: `Effect.dll!sub_10007650`; -- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`; -- alpha/time: `sub_10005C60`; -- exports: `CreateFxManager`, `InitializeSettings`. - -Проверка по данным: - -- `923/923` FXID payload валидны в `testdata/nres`. - ---- - -## 2. Контейнер и runtime API - -### 2.1. NRes entry - -FXID хранится как NRes-entry: - -- `type_id = 0x44495846` (`"FXID"`). - -Наблюдение по датасету (923 эффекта): - -- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`. - -### 2.2. Export API `Effect.dll` - -Экспортируются: - -- `CreateFxManager(int a1, int a2, int owner)`; -- `InitializeSettings()`. - -`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`). - -### 2.3. Интерфейс менеджера - -Рабочая vtable (`off_1001E478`): - -| Смещение | Функция | Назначение | -|---|---|---| -| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) | -| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource | -| +0x14 | `sub_10004590` | Создать runtime instance | -| +0x18 | `sub_10004780` | Удалить instance | -| +0x1C | `sub_100047B0` | Установить time/interp mode | -| +0x20 | `sub_100047D0` | Установить scale | -| +0x24 | `sub_10004830` | Установить позицию | -| +0x28 | `sub_10004930` | Установить matrix transform | -| +0x2C | `sub_10004B00` | Restart/retime | -| +0x38 | `sub_10004BA0` | Duration modifier | -| +0x3C | `sub_10004BD0` | Start/Enable | -| +0x40 | `sub_10004C10` | Stop/Disable | -| +0x44 | `sub_10004C50` | Bind emitter/context | -| +0x48 | `sub_10004D50` | Сброс frame flags | - -`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса. - ---- - -## 3. Бинарный формат FXID payload +## 2. Бинарный формат Все значения little-endian. -### 3.1. Header (60 байт, `0x3C`) +### 2.1. Заголовок (60 байт) ```c struct FxHeader60 { @@ -105,94 +36,26 @@ struct FxHeader60 { }; ``` -Командный поток начинается строго с `offset = 0x3C`. +Поток команд начинается строго с `offset = 0x3C`. -### 3.2. Header-поля (подтвержденная семантика) - -- `cmd_count`: число команд (engine итерирует ровно столько шагов). -- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`). -- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`. -- `phase_jitter`: используется при `flags & 0x1`. -- `flags`: runtime-gating/alpha/visibility (см. ниже). -- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`. -- `rand_shift_*`: используется при `flags & 0x8`. -- `pivot_*`: используется в ветках `sub_10007D10`. -- `scale_*`: копируется в runtime scale и влияет на матрицы. - -### 3.3. `flags` (битовая карта) - -| Бит | Маска | Наблюдаемое поведение | -|---|---:|---| -| 0 | `0x0001` | Random phase jitter (`phase_jitter`) | -| 3 | `0x0008` | Random positional shift (`rand_shift_*`) | -| 4 | `0x0010` | Visibility/occlusion ветки | -| 5 | `0x0020` | Triangular remap в `sub_10005C60` | -| 6 | `0x0040` | Инверсия начального active-state | -| 7 | `0x0080` | Day/night filter (ветка A) | -| 8 | `0x0100` | Day/night filter (ветка B, инверсия) | -| 9 | `0x0200` | Alpha *= normalized lifetime | -| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) | -| 11 | `0x0800` | Изменение gating в `sub_10007D10` | -| 12 | `0x1000` | Установка manager-state bit `0x10` | - -Нерасшифрованные биты должны сохраняться 1:1. - -### 3.4. `time_mode` (`0..17`) - -Обозначения (`sub_10005C60`): - -- `t0 = instance.start_ms`, `t1 = instance.end_ms`; -- `tn = (now_ms - t0) / (t1 - t0)`; -- `prev = instance.cached_alpha` (`v4+52` в дизассембле). - -Режимы: - -- `0`: constant (`instance.alpha_const`, поле `v4+40`); -- `1`: `tn`; -- `2`: `fract(tn)`; -- `3`: `1 - tn`; -- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`); -- `5`: `|param33.xyz| / |param17.vecA.xyz|`; -- `6`: `param33.x / param17.vecA.x`; -- `7`: `param33.y / param17.vecA.y`; -- `8`: `param33.z / param17.vecA.z`; -- `9`: `|param36.xyz| / |param17.vecB.xyz|`; -- `10`: `param36.x / param17.vecB.x`; -- `11`: `param36.y / param17.vecB.y`; -- `12`: `param36.z / param17.vecB.z`; -- `13`: `1 - external_resource_value`; -- `14`: `1 - queue_param(49)`; -- `15`: `max(norm(param33/vecA), norm(param36/vecB))`; -- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается); -- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается). - -Post-обработка после mode: - -- если `flags & 0x200`: `alpha *= tn`; -- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`). - ---- - -## 4. Командный поток - -### 4.1. Общий формат команды +### 2.2. Команда Каждая команда: -- `uint32 cmd_word`; -- далее body фиксированного размера по opcode. +1. `uint32 cmd_word` +2. body фиксированного размера, зависящего от `opcode` -`cmd_word`: +Поля `cmd_word`: -- `opcode = cmd_word & 0xFF`; -- `enabled = (cmd_word >> 8) & 1`; -- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1. +- `opcode = cmd_word & 0xFF` +- `enabled = (cmd_word >> 8) & 1` +- `bits 9..31` нужно сохранять 1:1 Выравнивания между командами нет. -### 4.2. Размеры +### 2.3. Размеры команд -| Opcode | Размер записи | +| Opcode | Размер | |---:|---:| | 1 | 224 | | 2 | 148 | @@ -205,630 +68,121 @@ Post-обработка после mode: | 9 | 208 | | 10 | 208 | -### 4.3. Opcode -> runtime-класс (vtable) +## 3. Смысл заголовка -| Opcode | `new(size)` | vtable | -|---:|---:|---| -| 1 | `0xF0` | `off_1001E78C` | -| 2 | `0xA0` | `off_1001F048` | -| 3 | `0xFC` | `off_1001E770` | -| 4 | `0x104` | `off_1001E754` | -| 5 | `0x54` | `off_1001E360` | -| 6 | `0x1C` | `off_1001E738` | -| 7 | `0x48` | `off_1001E228` | -| 8 | `0xAC` | `off_1001E71C` | -| 9 | `0x100` | `off_1001E700` | -| 10 | `0x48` | `off_1001E24C` | +- `cmd_count`: число команд в потоке. +- `time_mode`: способ вычисления текущего коэффициента эффекта. +- `duration_sec`: длительность (в рантайме переводится в миллисекунды). +- `phase_jitter`: амплитуда случайного фазового сдвига. +- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга). +- `settings_id`: индекс профиля/настроек эффекта. +- `rand_shift_*`: случайный пространственный сдвиг. +- `pivot_*`: локальная опора. +- `scale_*`: базовый масштаб инстанса эффекта. -### 4.4. Общий вызовной контракт команды +## 4. Флаги заголовка -После создания команды (`sub_10007650`): +Практически важные биты: -1. `cmd->enabled = cmd_word.bit8`. -2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`). -3. команда добавляется в список инстанса. +- `0x0001`: случайный сдвиг фазы +- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`) +- `0x0010`: ветки видимости/окклюзии +- `0x0020`: треугольный ремап альфы +- `0x0040`: инверсия исходного active-state +- `0x0080`, `0x0100`: фильтрация по времени суток +- `0x0200`: умножение альфы на нормализованное время жизни +- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта +- `0x0800`: дополнительный гейтинг -В runtime cycle: +Неизвестные биты должны сохраняться без изменений. -- `vfunc +8`: update/compute (bool); -- `vfunc +12`: emission/render callback; -- `vfunc +20`: toggle active; -- `vfunc +16`/`+24`: служебные функции (зависят от opcode). +## 5. `time_mode` (0..17) ---- +База: -## 5. Загрузка FXID (engine-accurate) +- `tn = (now - start) / (end - start)` +- `prev = предыдущая вычисленная альфа` -`sub_10007650`: +Поддерживаемые семейства режимов: -```c -void FxLoad(FxInstance* fx, uint8_t* payload) { - FxHeader60* h = (FxHeader60*)payload; +- константный режим; +- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`); +- режимы от внешних параметров мира/очереди; +- режимы на основе норм векторов состояния; +- режимы с ограничением вниз/вверх относительно `prev`. - fx->raw_header = h; - fx->mode = h->time_mode; - fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f; - fx->scale = {h->scale_x, h->scale_y, h->scale_z}; - fx->active_default = ((h->flags & 0x40) == 0); +После вычисления: - uint8_t* ptr = payload + 0x3C; - for (uint32_t i = 0; i < h->cmd_count; ++i) { - uint32_t w = *(uint32_t*)ptr; - uint8_t op = (uint8_t)(w & 0xFF); +- при `flags & 0x0200` применяется `alpha *= tn`; +- при `flags & 0x0020` применяется triangular remap. - Command* cmd = CreateByOpcode(op, ptr); // может вернуть null - if (cmd) { - cmd->enabled = (w >> 8) & 1; +## 6. Resource-ссылки внутри команд - if (h->flags & 0x400) fx->manager_flags |= 0x0100; - if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010; - - cmd->Init(fx->queue, fx); - fx->commands.push_back(cmd); - } - - ptr += size_by_opcode(op); // без bounds checks в оригинале - } -} -``` - -Критичные edge-case оригинала: - -- bounds checks отсутствуют; -- при unknown opcode `ptr` не двигается (`advance = 0`); -- при `new == null` команда пропускается, но `ptr` двигается. - -Фактический `advance` в `sub_10007650` задан hardcoded в DWORD: - -- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`, -- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`, -- `default:+0`. - ---- - -## 6. Runtime lifecycle - -- `sub_10007470`: ctor instance. -- `sub_10003D30(case 28)`: per-frame update manager. -- `sub_10006170`: gate + alpha/time + command updates. -- `sub_10008120` / `sub_10007D10`: update/render branches. -- Start/Stop: `sub_10004BD0` / `sub_10004C10`. - -Event-codes `sub_10003D30`: - -- `4`: bootstrap/time init; -- `20`: range-removal + index repair; -- `23`: set manager bit0; -- `24`: clear manager bit0; -- `28`: main tick. - ---- - -## 7. Общий тип `ResourceRef64` - -Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида: +Для opcode `2/3/4/5/7/8/9/10` используется ссылка: ```c struct ResourceRef64 { - char archive[32]; // null-terminated ASCII, case-insensitive compare - char name[32]; // null-terminated ASCII + char archive[32]; + char name[32]; }; ``` -Поведение loader'а: +Контракт: -- оба имени обязаны быть непустыми; -- кэширование по `(_strcmpi archive, _strcmpi name)`; -- загрузка/резолв через manager resource API. +- строки ASCII, нуль-терминированные; +- сравнение имён регистронезависимое; +- обычно: + - `opcode 2`: `sounds.lib` + `*.wav` + - остальные: `material.lib` + имя материала/эффекта. -Наблюдение по данным: +## 7. Runtime-контракт исполнения -- для `opcode 2`: обычно `sounds.lib` + `*.wav`; -- для остальных: обычно `material.lib` + material name. +На создании инстанса: ---- +1. Заголовок копируется в runtime-состояние. +2. Вычисляется `end_time`. +3. Для каждой команды создаётся runtime-объект по `opcode`. +4. В объект копируется `enabled`. +5. Объект инициализируется контекстом эффекта. -## 8. Полная карта body по opcode (field-level) +На каждом кадре: -Смещения указаны от начала команды (включая `cmd_word`). +1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`. +2. Выполняется update каждой команды. +3. Выполняется emit/render часть активных команд. +4. Применяются события Start/Stop/Restart. -### 8.1. Opcode 1 (`off_1001E78C`, size=224) +## 8. Строгий парсер (рекомендуемый) -Основные методы: +1. Проверить `len(payload) >= 60`. +2. Прочитать `cmd_count`. +3. Идти от `ptr = 0x3C`. +4. Для каждой команды: + - проверить `ptr + 4 <= len`; + - прочитать `opcode`; + - проверить, что `opcode` поддержан; + - проверить `ptr + size(opcode) <= len`; + - сдвинуть `ptr += size(opcode)`. +5. Проверить `ptr == len(payload)`. -- init: `sub_1000F4B0`; -- update: `sub_1000F6E0`; -- emit: `nullsub_2`; -- toggle: `sub_1000F490`. +## 9. Writer и редактор -```c -struct FxCmd01 { - uint32_t word; // +0 - uint32_t mode; // +4 (enum, см. ниже) - float t_start; // +8 - float t_end; // +12 +Для lossless-совместимости: - float p0_min[3]; // +16..24 - float p0_max[3]; // +28..36 - - float p1_min[3]; // +40..48 - float p1_max[3]; // +52..60 - - float q0_min[4]; // +64..76 - float q0_max[4]; // +80..92 - - float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0) - - float scalar_min; // +112 - float scalar_max; // +116 - float scalar_rand_amp; // +120 - - float color_rgb[3]; // +124..132 (вызов manager+16) - - float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0) - - char opt_archive[32]; // +160..191 (редко, напр. "material.lib") - char opt_name[32]; // +192..223 (редко, напр. "light_w") -}; -``` - -Замечания по полям op1: - -- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`; -- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1; -- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`. - -`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`): - -- `1 -> create_kind=1, flags=0x80000000`; -- `2/5 -> create_kind=1, flags=0x00000000`; -- `3 -> create_kind=3, flags=0x00000000`; -- `4 -> create_kind=4, flags=0x00000000`; -- `6 -> create_kind=1, flags=0xA0000000`; -- `7 -> create_kind=1, flags=0x20000000`. - -### 8.2. Opcode 2 (`off_1001F048`, size=148) - -Основные методы: - -- init: `sub_10012D10`; -- update: `sub_10012EB0`; -- emit: `nullsub_2`; -- toggle: `sub_10013170`. - -```c -struct FxCmd02 { - uint32_t word; // +0 - uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping) - float t_start; // +8 - float t_end; // +12 - - float a_min[3]; // +16..24 - float a_max[3]; // +28..36 - - float b_min[3]; // +40..48 - float b_max[3]; // +52..60 - - float c0_base; // +64 - float c1_base; // +68 - float c2_base; // +72 - float c2_max; // +76 - - uint32_t param_910; // +80 (передаётся в manager cmd=910) - - ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav) -}; -``` - -`mode` -> внутренний map в `sub_100065A0`: - -- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`. - -### 8.3. Opcode 3 (`off_1001E770`, size=200) - -Методы: - -- init: `sub_100103B0`; -- update: `sub_100105F0`; -- emit: `sub_100106C0`. - -```c -struct FxCmd03 { - uint32_t word; // +0 - uint32_t mode; // +4 - - float alpha_source; // +8 (>=0: norm time, <0: global time) - float alpha_pow_a; // +12 - float alpha_pow_b; // +16 - - float out_min; // +20 - float out_max; // +24 - float out_pow; // +28 - - float active_t0; // +32 - float active_t1; // +36 - - float v0_min[3]; // +40..48 - float v0_max[3]; // +52..60 - - float pow0[3]; // +64..72 - - float v1_min[3]; // +76..84 - float v1_max[3]; // +88..96 - - float v2_min[3]; // +100..108 - float v2_max[3]; // +112..120 - - float pow1[3]; // +124..132 - - ResourceRef64 ref; // +136..199 -}; -``` - -### 8.4. Opcode 4 (`off_1001E754`, size=204) - -Layout как opcode 3 + последний коэффициент: - -```c -struct FxCmd04 { - FxCmd03 base; // +0..199 - float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0) -}; -``` - -`sub_100108C0`: `obj->inv = 1.0 / raw[200]`. - -### 8.5. Opcode 5 (`off_1001E360`, size=112) - -Методы: - -- init: `sub_100028A0`; -- update: `sub_10002A20`; -- emit: `sub_10002BE0`; -- context update: `sub_10003070`. - -```c -struct FxCmd05 { - uint32_t word; // +0 - uint32_t mode; // +4 (в данных обычно 1) - uint32_t unused_08; // +8 (в текущем коде opcode5 не читается) - uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается) - - float active_t0; // +16 - uint32_t max_segments; // +20 - float active_t1_min; // +24 - float active_t1_max; // +28 - - float step_norm; // +32 - float segment_len; // +36 - float alpha_source; // +40 (>=0 norm, <0 random) - float alpha_pow; // +44 - - ResourceRef64 ref; // +48..111 -}; -``` - -### 8.6. Opcode 6 (`off_1001E738`, size=4) - -Только `cmd_word`: - -```c -struct FxCmd06 { - uint32_t word; // +0 -}; -``` - -`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`). - -### 8.7. Opcode 7 (`off_1001E228`, size=208) - -Методы: - -- init: `sub_10001720`; -- update: `sub_10001230`; -- emit: `sub_10001300`; -- element accessor: `sub_10002780`. - -```c -struct FxCmd07 { - uint32_t word; // +0 - uint32_t mode; // +4 - - float eval_min; // +8 - float eval_max; // +12 - float eval_pow; // +16 - - float active_t0; // +20 - float active_t1; // +24 - - float phase_span; // +28 - float phase_rate; // +32 - - uint32_t count_a; // +36 - uint32_t count_b; // +40 - - float set0_min[3]; // +44..52 - float set0_max[3]; // +56..64 - float set0_rand[3]; // +68..76 - float set0_pow[3]; // +80..88 - - float set1_min[3]; // +92..100 - float set1_max[3]; // +104..112 - float set1_rand[3]; // +116..124 - float set1_pow[3]; // +128..136 - - float gravity_or_drag_k; // +140 - - ResourceRef64 ref; // +144..207 -}; -``` - -### 8.8. Opcode 8 (`off_1001E71C`, size=248) - -Методы: - -- init: `sub_10011230`; -- update: `sub_100115C0`; -- emit: `sub_10012030`. - -```c -struct FxCmd08 { - uint32_t word; // +0 - uint32_t mode; // +4 - - float eval_t0; // +8 - float eval_t1; // +12 - - float gate_t0; // +16 - float gate_t1; // +20 - - float period_min; // +24 - float period_max; // +28 - float phase_pow; // +32 - - uint32_t slots; // +36 - - float set0_min[3]; // +40..48 - float set0_max[3]; // +52..60 - float set0_rand[3]; // +64..72 - - float set1_min[3]; // +76..84 - float set1_max[3]; // +88..96 - float set1_rand[3]; // +100..108 - - float set2_rand[3]; // +112..120 - float set2_pow[3]; // +124..132 - - float rmax_set0[3]; // +136..144 (bound/radius calc) - float rmax_set1[3]; // +148..156 (bound/radius calc) - float rmax_set2[3]; // +160..168 (bound/radius calc) - - float render_pow[3]; // +172..180 - - ResourceRef64 ref; // +184..247 -}; -``` - -### 8.9. Opcode 9 (`off_1001E700`, size=208) - -Layout как opcode 3 с двумя final-полями: - -```c -struct FxCmd09 { - FxCmd03 base; // +0..199 - uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0) - uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000) -}; -``` - -Методы: - -- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`); -- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`. - -### 8.10. Opcode 10 (`off_1001E24C`, size=208) - -Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс. - -- init: `sub_10001A40`; -- update: `sub_10001230`; -- emit: `sub_10001300`; -- element accessor: `sub_10002830`. - -Наблюдение по данным: - -- `mode` (`+4`) встречается как `16` или `32`. - ---- - -## 9. Runtime-специфика по opcode (важные отличия) - -### 9.1. Opcode 1 - -- создаёт handle через manager (`vfunc +48`); -- задаёт флаги handle (`vfunc +52`); -- в update пушит: - - позиционный вектор 1 (`vfunc +32`), - - позиционный вектор 2 (`vfunc +36`), - - 4-компонентный параметр (`vfunc +12`), - - scalar+rgb (`vfunc +16`). - -### 9.2. Opcode 2 - -- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`); -- использует manager-команду id `910`. - -### 9.3. Opcode 3/4/9 - -- общий core-emitter в `sub_100106C0`; -- opcode 4 добавляет нормализацию по `raw+200`; -- opcode 9 добавляет переключение render-кода (`raw+200/+204`). - -### 9.4. Opcode 5 - -- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`); -- context-matrix приходит через `vfunc +24` (`sub_10003070`). - -### 9.5. Opcode 7/10 - -- общий update/render (`sub_10001230`, `sub_10001300`); -- разные внутренние element-форматы: - - opcode 7: `204` байта/элемент (`sub_100092D0`), - - opcode 10: `492` байта/элемент (`sub_1000BB40`). - -### 9.6. Opcode 8 - -- самый тяжёлый спавнер, хранит ring/slot-структуры; -- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms. - ---- - -## 10. Спецификация инструментов - -### 10.1. Reader (strict) - -Алгоритм: - -1. `len(payload) >= 60`; -2. читаем `cmd_count`; -3. `ptr = 0x3C`; -4. цикл `cmd_count`: - - `ptr + 4 <= len`; - - `opcode in 1..10`; - - `ptr + size(opcode) <= len`; - - `ptr += size(opcode)`; -5. strict-tail: `ptr == len(payload)`. - -### 10.2. Reader (engine-compatible) - -Legacy-режим (опасный, только при необходимости byte-совместимости): - -- без bounds-check; -- tolerant к unknown opcode как в оригинале. - -### 10.3. Writer (canonical) - -1. записать `FxHeader60`; -2. `cmd_count = commands.len()`; -3. команды сериализуются как `cmd_word + fixed-body`; -4. размер payload: `0x3C + sum(size(op_i))`; -5. без хвостовых байт. - -### 10.4. Editor (lossless) - -Правила: - -- все поля little-endian; -- не менять fixed size команды; +- сохранять все неизвестные поля/биты; +- не менять фиксированные размеры команд; - не добавлять padding; -- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through; -- для частично-известных полей поддерживать режим `opaque`. +- пересчитывать только `cmd_count` и размеры контейнера; +- сохранять порядок команд. -### 10.5. IR/JSON (рекомендуемая форма) +## 10. Что требуется для 1:1 переноса -```json -{ - "header": { - "time_mode": 1, - "duration_sec": 2.5, - "phase_jitter": 0.2, - "flags": 22, - "settings_id": 785, - "rand_shift": [0.0, 0.0, 0.0], - "pivot": [0.0, 0.0, 0.0], - "scale": [1.0, 1.0, 1.0] - }, - "commands": [ - { - "opcode": 8, - "word_raw": 264, - "enabled": 1, - "fields": { - "mode": 1065353216, - "eval_t0": 0.0, - "eval_t1": 1.0, - "resource": {"archive": "material.lib", "name": "fire_smoke"} - }, - "opaque_extra_hex": "..." - } - ] -} -``` +1. Полная поддержка opcode `1..10`. +2. Точный контракт вычисления `time_mode` и `flags`. +3. Точное поведение `ResourceRef64`. +4. Повторяемый RNG и одинаковая политика плавающей точки. ---- +## 11. Статус валидации -## 11. Проверка на реальных данных - -`testdata/nres`: - -- FXID payload: `923`; -- валидация parser'а: `923/923 valid`. - -Распределение opcode: - -- `1: 618` -- `2: 517` -- `3: 1545` -- `4: 202` -- `5: 31` -- `6: 0` (в датасете не встречен, но поддержан) -- `7: 1161` -- `8: 237` -- `9: 266` -- `10: 160` - -Подтверждённые `ResourceRef64` оффсеты: - -- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`. - -Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`: - -- `material.lib` / `light_w`. - ---- - -## 12. Практический чек-лист 1:1 - -Для runtime-порта: - -- реализовать `FxHeader60` и parser `sub_10007650`; -- реализовать opcode-классы с методами как в vtable; -- учитывать start/stop/restart контракт manager API; -- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`); -- воспроизвести event loop `sub_10003D30(case 28)`. - -Для toolchain: - -- strict validator по разделу 10.1; -- canonical writer по разделу 10.3; -- field-aware editor + opaque fallback для неизвестных зон. - ---- - -## 13. Что считать «полной» совместимостью - -Практический критерий завершения: - -1. Парсер и writer дают byte-identical round-trip для всех 923 FXID. -2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state). -3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете). -4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом. - -Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode. - ---- - -## 14. Что осталось до «абсолютных 100%» - -Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно. -Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта: - -1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах. -2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала. -3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы. - -Что нужно собрать, чтобы закрыть это полностью: - -- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state); -- контрольные прогоны при фиксированном `dt` и seed; -- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`). +- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`. +- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась. diff --git a/docs/specs/material.md b/docs/specs/material.md new file mode 100644 index 0000000..cd7eea5 --- /dev/null +++ b/docs/specs/material.md @@ -0,0 +1,130 @@ +# Material (`MAT0`) + +`MAT0` описывает материал и его фазовую анимацию. + +Связанные страницы: + +- [Wear table (`WEAR`)](wear.md) +- [Texture (`Texm`)](texture.md) +- [Render pipeline](render.md) + +## 1. Контейнер + +- Тип ресурса: `0x3054414D` (`MAT0`). +- Обычно хранится в `Material.lib`. +- `attr1` используется как битовое поле runtime-флагов материала. +- `attr2` задаёт версию заголовка payload. + +## 2. Бинарный layout + +```c +struct Mat0Payload { + uint16_t phaseCount; + uint16_t animBlockCount; // должно быть < 20 + + // если attr2 >= 2 + uint8_t metaA8; + uint8_t metaB8; + // если attr2 >= 3 + uint32_t metaC32; + // если attr2 >= 4 + uint32_t metaD32; + + PhaseRecord34 phases[phaseCount]; + AnimBlockRaw anim[animBlockCount]; +}; +``` + +Если `attr2 < 2`, используются runtime-значения по умолчанию: + +- `metaA = 255` +- `metaB = 255` +- `metaC = 1.0f` +- `metaD = 0` + +## 3. Фазы материала + +```c +struct PhaseRecord34 { + uint8_t params[18]; + char textureName[16]; +}; +``` + +В рантайме запись разворачивается в структуру ~76 байт: + +- набор коэффициентов цвета/освещения/прозрачности; +- индекс слота текстуры; +- дополнительные целочисленные поля. + +`textureName`: + +- пустая строка -> фаза без текстуры (`texSlot = -1`); +- непустая строка -> загрузка текстуры по имени. + +## 4. Анимационные блоки + +```c +struct AnimBlockRaw { + uint32_t headerRaw; // mode = low 3 bits, interpMask = остальные + uint16_t keyCount; + KeyRaw keys[keyCount]; +}; + +struct KeyRaw { + uint16_t k0; + uint16_t k1; + uint16_t k2; // opaque, сохранять 1:1 +}; +``` + +`k2` нельзя удалять или нормализовать: это часть бинарного контракта. + +## 5. Выбор текущей фазы + +Материал выбирает фазу по времени и по режиму анимации блока: + +- loop; +- ping-pong; +- one-shot с clamp; +- random-offset. + +При смешивании интерполируется только часть полей, остальные копируются из активной фазы. +Для 1:1 совместимости важно сохранить эту выборочную интерполяцию. + +## 6. Загрузка и fallback + +При запросе материала по имени: + +1. Точный поиск по имени. +2. Если не найдено — fallback на `DEFAULT`. +3. Если `DEFAULT` отсутствует — используется запись с индексом `0`. + +## 7. Атрибуты и флаги + +Практически важные биты `attr1`: + +- бит загрузки текстурной фазы с расширенными флагами; +- флаги аппаратного профиля; +- 4-битный режим (`nibbleMode`); +- дополнительный флаг material-поведения. + +Неизвестные биты должны сохраняться без изменений. + +## 8. Ограничения + +- `animBlockCount < 20` +- `phaseCount` и фактический размер секции фаз должны совпадать +- `textureName` должен быть NUL-terminated и укладываться в 16 байт + +## 9. Правила writer/editor + +1. Сохранять `attr1/attr2/attr3`. +2. Не менять `metaA/B/C/D` без явного запроса. +3. Сохранять opaque-поля анимации (включая `k2`) 1:1. +4. Проверять выход за границы payload при парсинге. + +## 10. Статус валидации + +- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`). +- В этом окружении нет полного игрового корпуса, поэтому статистика по всем материалам не пересчитывалась. diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md index baa80ae..0397c84 100644 --- a/docs/specs/materials-texm.md +++ b/docs/specs/materials-texm.md @@ -1,874 +1,8 @@ -# Materials, WEAR, MAT0 и Texm +# Materials, WEAR, Texm -Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для: +Старая объединённая страница разбита по объектам. -- реализации runtime 1:1; -- создания инструментов чтения/валидации; -- создания инструментов конвертации и редактирования с lossless round-trip. - -Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`. - ---- - -## 1. Идентификаторы и сущности - -| Сущность | ID (LE uint32) | ASCII | Где используется | -|---|---:|---|---| -| Material resource | `0x3054414D` | `MAT0` | `Material.lib` | -| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` | -| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` | -| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` | - -Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`. - ---- - -## 2. Архитектура подсистемы - -### 2.1 Экспортируемые точки входа (World3D) - -- `LoadMatManager` -- `SetPalettesLib` -- `SetTexturesLib` -- `SetMaterialLib` -- `SetLightMapLib` -- `SetGameTime` -- `UnloadAllTextures` - -`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет. - -### 2.2 Дефолтные библиотеки (из `iron3d.dll`) - -- `Textures.lib` -- `Material.lib` -- `LightMap.lib` -- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`) - -### 2.3 Ключевые runtime-хранилища - -1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт. -2. Кэш текстурных объектов. -3. Кэш lightmap-объектов. -4. Банк загруженных палитр. -5. Глобальный пул определений материалов (`MAT0`). - ---- - -## 3. Layout `MatManager` (0x470) - -Объект содержит 70 таблиц wear/lightmaps (не 140). - -```c -// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470 -// [0] vtable -// [1] callback iface -// [2] callback data -// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт -// [73..142] wearCounts[70] -// [143] tableCount -// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта -// [214..283] lightmapCounts[70] -``` - -### 3.1 Vtable методов (`off_100209E4`) - -| Индекс | Функция | Назначение | -|---:|---|---| -| 0 | `loc_10002CE0` | служебный/RTTI-заглушка | -| 1 | `sub_10002D10` | деструктор + освобождение таблиц | -| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) | -| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` | -| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` | -| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` | -| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) | -| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера | -| 8 | `sub_100031A0` | получить указатель на lightmap texture object | -| 9 | `sub_10003AB0` | получить runtime-метаданные материала | -| 10 | `sub_100031D0` | получить `wearCount` для таблицы | - -### 3.2 Кодирование material-handle - -`uint32 handle = (tableIndex << 16) | wearIndex`. - -- `HIWORD(handle)` -> индекс таблицы `0..69` -- `LOWORD(handle)` -> индекс материала в wear-таблице - ---- - -## 4. Глобальные кэши и их ёмкость - -Ёмкости подтверждены границами циклов/адресов в дизассемблере. - -### 4.1 Кэш текстур (`dword_1014E910`...) - -- Размер слота: `5 DWORD` (20 байт) -- Ёмкость: `777` - -```c -struct TextureSlot { - int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно - void* textureObject; // +4 - int32_t refCount; // +8 - uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0 - uint32_t loadFlags; // +16 флаги загрузки -}; -``` - -`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC. - -### 4.2 Кэш lightmaps (`dword_10029C98`...) - -- Тот же layout `5 DWORD` -- Ёмкость: `100` - -Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается. - -### 4.3 Пул материалов (`dword_100669F0`...) - -- Шаг: `92 DWORD` (`368` байт) -- Ёмкость: `700` - -Фиксированные поля на шаг `i*92`: - -| DWORD offset | Byte offset | Поле | -|---:|---:|---| -| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free | -| 1 | 4 | `refCount` | -| 2 | 8 | `phaseCount` | -| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) | -| 4 | 16 | `animBlockCount` (`< 20`) | -| 5..84 | 20..339 | `animBlocks[20]` по 16 байт | -| 85 | 340 | metaA (`dword_10066B44`) | -| 86 | 344 | metaB (`dword_10066B48`) | -| 87 | 348 | metaC (`dword_10066B4C`) | -| 88 | 352 | metaD (`dword_10066B50`) | -| 89 | 356 | flagA (`dword_10066B54`) | -| 90 | 360 | nibbleMode (`dword_10066B58`) | -| 91 | 364 | flagB (`dword_10066B5C`) | - -### 4.4 Банк палитр - -- `dword_1013DA58[]` -- Загружается до `286` элементов (26 букв * 11 вариантов) - ---- - -## 5. Загрузка палитр (`sub_10002B40`) - -### 5.1 Генерация имён - -Движок перебирает: - -- буквы `'A'..'Z'` -- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"` - -И формирует имя: - -- `.PAL` -- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL` - -### 5.2 Индекс палитры - -`paletteIndex = letterIndex * 11 + variantIndex` - -- `letterIndex = 0..25` -- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10) - -### 5.3 Поведение - -- Если запись не найдена: `paletteSlots[idx] = 0` -- Если найдена: payload отдаётся в рендер (`render->method+60`) - ---- - -## 6. Формат `MAT0` (`Material.lib`) - -### 6.1 Атрибуты NRes entry - -`sub_10004310` использует: - -- `entry.type` = `MAT0` -- `entry.attr1` (bitfield runtime-флагов) -- `entry.attr2` (версия/вариант заголовка payload) -- `entry.attr3` не используется в runtime-парсере - -Маппинг `attr1`: - -- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы -- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000` -- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF` -- bit6 (`0x40`) -> `flagB=1` - -### 6.2 Payload layout - -```c -struct Mat0Payload { - uint16_t phaseCount; - uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material." - - // Если attr2 >= 2: - uint8_t metaA8; - uint8_t metaB8; - // Если attr2 >= 3: - uint32_t metaC32; - // Если attr2 >= 4: - uint32_t metaD32; - - PhaseRecordByte34 phases[phaseCount]; - AnimBlockRaw anim[animBlockCount]; -}; -``` - -Если `attr2 < 2`, runtime-значения по умолчанию: - -- `metaA = 255` -- `metaB = 255` -- `metaC = 1.0f` (`0x3F800000`) -- `metaD = 0` - -### 6.3 `PhaseRecordByte34` -> runtime `76 bytes` - -Сырые 34 байта: - -```c -struct PhaseRecordByte34 { - uint8_t p[18]; // параметры - char textureName[16];// если textureName[0]==0, текстуры нет -}; -``` - -Преобразование в runtime-структуру (точный порядок): - -| Из `p[i]` | В offset runtime | Преобразование | -|---:|---:|---| -| `p[0]` | `+16` | `p[0] / 255.0f` | -| `p[1]` | `+20` | `p[1] / 255.0f` | -| `p[2]` | `+24` | `p[2] / 255.0f` | -| `p[3]` | `+28` | `p[3] * 0.01f` | -| `p[4]` | `+0` | `p[4] / 255.0f` | -| `p[5]` | `+4` | `p[5] / 255.0f` | -| `p[6]` | `+8` | `p[6] / 255.0f` | -| `p[7]` | `+12` | `p[7] / 255.0f` | -| `p[8]` | `+32` | `p[8] / 255.0f` | -| `p[9]` | `+36` | `p[9] / 255.0f` | -| `p[10]` | `+40` | `p[10] / 255.0f` | -| `p[11]` | `+44` | `p[11] / 255.0f` | -| `p[12]` | `+48` | `p[12] / 255.0f` | -| `p[13]` | `+52` | `p[13] / 255.0f` | -| `p[14]` | `+56` | `p[14] / 255.0f` | -| `p[15]` | `+60` | `p[15] / 255.0f` | -| `p[16]` | `+64` | `uint32 = p[16]` | -| `p[17]` | `+72` | `int32 = p[17]` | - -Текстура: - -- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1` -- иначе `runtime[+68] = LoadTexture(textureName, flags)` - -### 6.4 Runtime-запись фазы (76 байт) - -```c -struct MaterialPhase76 { - float f0; // +0 - float f1; // +4 - float f2; // +8 - float f3; // +12 - float f4; // +16 - float f5; // +20 - float f6; // +24 - float f7; // +28 - float f8; // +32 - float f9; // +36 - float f10; // +40 - float f11; // +44 - float f12; // +48 - float f13; // +52 - float f14; // +56 - float f15; // +60 - uint32_t u16; // +64 - int32_t texSlot; // +68 (индекс в texture cache, либо -1) - int32_t i18; // +72 -}; -``` - -### 6.5 Анимационные блоки (`animBlockCount`, максимум 19) - -Каждый блок в payload: - -```c -struct AnimBlockRaw { - uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3 - uint16_t keyCount; - struct KeyRaw { - uint16_t k0; - uint16_t k1; - uint16_t k2; - } keys[keyCount]; -}; -``` - -Runtime-представление блока = 16 байт: - -```c -struct AnimBlockRuntime { - uint32_t mode; // headerRaw & 7 - uint32_t interpMask;// headerRaw >> 3 - int32_t keyCount; - void* keysPtr; // массив keyCount * 8 -}; -``` - -Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`). - -`k2` в `sub_100031F0/sub_10003680` не используется. -Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате. - -### 6.6 Поиск и fallback - -При `LoadMaterial(name)`: - -- сначала точный поиск в `Material.lib`; -- при промахе лог: `"Material %s not found."`; -- fallback на `DEFAULT`; -- если и `DEFAULT` не найден, берётся индекс `0`. - ---- - -## 7. Выбор текущей material-фазы - -### 7.1 Интерполяция (`sub_10003030`) - -Интерполируются только следующие поля (по `interpMask`): - -- bit `0x02`: `+4,+8,+12` -- bit `0x01`: `+20,+24,+28` -- bit `0x04`: `+36,+40,+44` -- bit `0x08`: `+52,+56,+60` -- bit `0x10`: `+32` - -Не интерполируются и копируются из «текущей» фазы: - -- `+0,+16,+48,+64,+68,+72` - -### 7.2 Выбор по времени (`sub_100031F0`) - -Вход: - -- `handle` (`tableIndex|wearIndex`) -- `animBlockIndex` -- глобальное время `SetGameTime()` (`dword_10032A38`) - -Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`). - -Режимы `mode = headerRaw & 7`: - -- `0`: loop -- `1`: ping-pong -- `2`: one-shot clamp -- `3`: random (`rand() % cycleLength`) - -Важные детали 1:1: - -- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением); -- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную). -- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case). - -После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр. - -### 7.3 Выбор по нормализованному `t` (`sub_10003680`) - -Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`. - -Перед вычислением времени применяется runtime-нормализация: - -- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`. - -### 7.4 Сброс времени записи - -`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`. - ---- - -## 8. Формат `WEAR` (текст) - -`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`. - -### 8.1 Грамматика - -```text -\n - \n // повторить wearCount раз - -[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка -[LIGHTMAPS\n -\n - \n // повторить lightmapCount раз] -``` - -- `` читается, но как ключ не используется. -- Идентификатором реально является имя (`materialName` / `lightmapName`). - -### 8.2 Парсеры - -1. `sub_10003B10`: файл/ресурсный режим. -2. `sub_10003F80`: парсер из строкового буфера. - -Различие важно для совместимости: - -- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`. -- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать. - -### 8.3 Поведение и ошибки - -- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."` -- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."` -- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."` -- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT` -- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1` -- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения. - -### 8.4 Ограничения runtime - -- Таблиц в `MatManager`: максимум 70 (физический layout). -- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет. - -Инструментам нужно явно валидировать `tableCount < 70`. - ---- - -## 9. Загрузка texture/lightmap по имени - -Общие функции: - -- `sub_10004B10` — texture (`Textures.lib`) -- `sub_10004CB0` — lightmap (`LightMap.lib`) - -### 9.1 Валидация имени - -Алгоритм требует наличие `'.'` в позиции `0..16`. - -Иначе: - -- `"Bad texture name."` -- возврат `-1` - -### 9.2 Palette index из суффикса - -После точки разбирается: - -- `L = toupper(name[dot+1])` -- `D = name[dot+2]` (опционально) -- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)` - -Если `idx < 0`, палитра не подставляется (`0`). -Верхняя граница `idx` в runtime не проверяется. - -Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки. -Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива. - -### 9.3 Кэширование - -- Дедупликация по `resIndex`. -- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`. -- При освобождении материала `refCount` texture/lightmap уменьшается. -- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд. -- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor). - ---- - -## 10. Формат `Texm` - -### 10.1 Заголовок 32 байта - -```c -struct TexmHeader32 { - uint32_t magic; // 'Texm' = 0x6D786554 - uint32_t width; - uint32_t height; - uint32_t mipCount; - uint32_t flags4; - uint32_t flags5; - uint32_t unk6; - uint32_t format; -}; -``` - -### 10.2 Поддерживаемые `format` - -Подтверждённые в данных: - -- `0` (палитровый 8-bit) -- `565` -- `4444` -- `888` -- `8888` - -Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации): - -- `556` -- `88` - -### 10.3 Layout payload - -1. `TexmHeader32` -2. если `format == 0`: palette table `256 * 4 = 1024` байта -3. mip-chain пикселей -4. опциональный `Page` chunk - -Расчёт: - -```c -bytesPerPixel = - (format == 0) ? 1 : - (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 : - 4; - -pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i)); -sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount; -``` - -### 10.4 `Page` chunk - -```c -struct PageChunk { - uint32_t magic; // 'Page' - uint32_t rectCount; - struct Rect16 { - int16_t x; - int16_t w; - int16_t y; - int16_t h; - } rects[rectCount]; -}; -``` - -Runtime конвертирует `Rect16` в: - -- пиксельные прямоугольники; -- UV-границы с учётом возможного `mipSkip`. - -Формулы (`s = mipSkip`): - -- `x0 = x << s`, `x1 = (x + w) << s` -- `y0 = y << s`, `y1 = (y + h) << s` -- `u0 = x / (width << s)`, `du = w / (width << s)` -- `v0 = y / (height << s)`, `dv = h / (height << s)` - -Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`. - -### 10.5 Loader-поведение (`sub_1000FB30`) - -- Читает header в внутренние поля (`+56..+84`) напрямую: - - `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`, - - `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`. -- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу. -- Считает `sizeCore`, находит tail. -- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`. -- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов. -- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime. - -### 10.6 Политика `mipSkip` (`sub_1000F580`) - -`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`: - -- если `mipCount <= 1` -> `0` -- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1` -- если `flags5Mask == 0x10000000` -> `1` -- если `flags5Mask == 0x20000000`: - - `1`, если `width >= 256` или `height >= 256` - - иначе `0` -- если `flags5Mask == 0x40000000`: - - если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1` - - если `width == 128` или `height == 128`: `1` - - иначе `0` -- иначе `0` - -Применение в loader: - -- `mipCount -= mipSkip` -- `width >>= mipSkip`, `height >>= mipSkip` -- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1` -- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня) - ---- - -## 11. Флаги профиля/рендера (Ngi32) - -Ключ реестра: `HKCU\Software\Nikita\NgiTool`. - -Подтверждённые значения: - -- `Disable MultiTexturing` -- `DisableMipmap` -- `Force 16-bit textures` -- `UseFirstCard` -- `DisableD3DCalls` -- `DisableDSound` -- `ForceCpu` - -Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки. - ---- - -## 12. Спецификация для toolchain (read/edit/write) - -### 12.1 Каноническая модель данных - -1. `MAT0`: -- хранить исходные `attr1/attr2/attr3`; -- хранить сырой payload + декодированную структуру; -- при записи сохранять порядок/размеры секций точно. - -2. `WEAR`: -- хранить строки wear/lightmaps как текст; -- сохранять порядок строк; -- допускать отсутствие блока `LIGHTMAPS`. -- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`. - -3. `Texm`: -- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать); -- хранить palette (если есть), mip data, `Page`. - -### 12.2 Правила lossless записи - -- Не менять значения `flags4/flags5/unk6` без явной причины. -- Не менять `NRes` entry attrs, если цель — бинарный round-trip. -- Для `MAT0`: - - `animBlockCount < 20`. - - `phaseCount` и фактический размер секции должны совпадать. - - textureName в фазе всегда укладывать в 16 байт и NUL-терминировать. -- Для `Texm`: - - `magic == 'Texm'`. - - `mipCount > 0`, `width>0`, `height>0`. - - tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт. - - при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`. - -### 12.3 Рекомендованные валидации редактора - -- `WEAR`: - - `wearCount > 0`. - - число строк wear соответствует `wearCount`. - - если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает. - - для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`. -- `MAT0`: - - не выходить за payload при распаковке. - - все ссылки фаз/keys проверять на диапазоны. -- `Texm`: - - `sizeCore <= payload_size`. - - проверка `Page` как `8 + rectCount*8`. - - предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime. - ---- - -## 13. Проверка на реальных данных (`tmp/gamedata`) - -### 13.1 `Material.lib` - -- `905` entries, все `type=MAT0` -- `attr2 = 6` у всех -- `attr3 = 0` у всех -- `phaseCount` до `29` -- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается) - -### 13.2 `Textures.lib` - -- `393` entries, все `type=Texm` -- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)` -- `flags4`: `32(361), 0(32)` -- `flags5`: `0(312), 0x04000000(81)` -- `Page` chunk присутствует у `65` текстур - -### 13.3 `lightmap.lib` - -- `25` entries, все `Texm` -- формат: `565` -- `mipCount=1` -- `flags5`: в основном `0`, встречается `0x00800000` - -### 13.4 `WEAR` - -- `439` entries `type=WEAR` -- `attr1=0, attr2=0, attr3=1` -- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`) -- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`. - ---- - -## 14. Opaque-поля и границы знания - -Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`: - -- `MAT0`: - - `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений); - - `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено). -- `Texm`: - - `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1). - -Это не блокирует реализацию движка/конвертеров 1:1. - ---- - -## 15. Минимальные псевдокоды для реализации - -### 15.1 `parse_mat0(payload, attr2)` - -```python -def parse_mat0(payload: bytes, attr2: int): - cur = 0 - phase_count = u16(payload, cur); cur += 2 - anim_count = u16(payload, cur); cur += 2 - if anim_count >= 20: - raise ValueError("Too many animations for material") - - if attr2 < 2: - metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0 - else: - metaA = u8(payload, cur); cur += 1 - metaB = u8(payload, cur); cur += 1 - metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000 - cur += 4 if attr2 >= 3 else 0 - metaD = u32(payload, cur) if attr2 >= 4 else 0 - cur += 4 if attr2 >= 4 else 0 - - phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)] - cur += 34 * phase_count - - anim = [] - for _ in range(anim_count): - raw = u32(payload, cur); cur += 4 - key_count = u16(payload, cur); cur += 2 - keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)] - cur += 6 * key_count - anim.append((raw, keys)) - - if cur != len(payload): - raise ValueError("MAT0 tail bytes") - - return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim -``` - -### 15.2 `parse_texm(payload)` - -```python -def parse_texm(payload: bytes): - magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0) - if magic != 0x6D786554: - raise ValueError("not Texm") - - bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4) - pix = 0 - mw, mh = w, h - for _ in range(mips): - pix += mw * mh - mw = max(1, mw >> 1) - mh = max(1, mh >> 1) - - core = 32 + (1024 if fmt == 0 else 0) + bpp * pix - if core > len(payload): - raise ValueError("truncated") - - page = None - if core < len(payload): - if core + 8 > len(payload) or payload[core:core+4] != b"Page": - raise ValueError("tail without Page") - n = u32(payload, core + 4) - need = 8 + n * 8 - if core + need != len(payload): - raise ValueError("invalid Page size") - page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)] - - return (w, h, mips, fmt, f4, f5, unk6, page) -``` - -### 15.3 `mip_skip_policy(flags5, width, height, mip_count)` - -```python -def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int: - if mip_count <= 1: - return 0 - - m = flags5 & 0x72000000 - if m == 0x02000000: - return 2 if mip_count > 2 else 1 - if m == 0x10000000: - return 1 - if m == 0x20000000: - return 1 if (width >= 256 or height >= 256) else 0 - if m == 0x40000000: - if width > 128 and height > 128: - return 2 if mip_count > 2 else 1 - if width == 128 or height == 128: - return 1 - return 0 -``` - -### 15.4 `parse_wear_buffer_compatible(text)` - -```python -def parse_wear_buffer_compatible(text: str): - lines = text.splitlines() - i = 0 - - wear_count = int(lines[i].strip()); i += 1 - if wear_count <= 0: - raise ValueError("Illegal wear length.") - - wear = [] - for _ in range(wear_count): - legacy, name = lines[i].split(maxsplit=1) - wear.append((int(legacy), name.strip())) - i += 1 - - lightmaps = [] - tail = lines[i:] if i < len(lines) else [] - if tail and tail[0].strip() == "": - # sub_10003F80-совместимый разделитель перед LIGHTMAPS - i += 1 - tail = lines[i:] - - if tail and tail[0].strip().upper() == "LIGHTMAPS": - i += 1 - if i >= len(lines): - raise ValueError("Illegal lightmaps length.") - light_count = int(lines[i].strip()); i += 1 - if light_count <= 0: - raise ValueError("Illegal lightmaps length.") - for _ in range(light_count): - legacy, name = lines[i].split(maxsplit=1) - lightmaps.append((int(legacy), name.strip())) - i += 1 - - return wear, lightmaps -``` - -### 15.5 `select_phase_time_1to1(...)` - -```python -def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int): - # keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len - cycle_len = keys[-1][2] - if cycle_len <= 0: - return 0, 0.0 - - # unsigned div/mod как в runtime - delta = (game_time - start_time) & 0xFFFFFFFF - q = delta // cycle_len - r = delta % cycle_len - - if mode == 1: # ping-pong - if q & 1: - r = cycle_len - r - elif mode == 2: # one-shot - if q > 0: - k = len(keys) - 1 - return k, 0.0 - elif mode == 3: # random - r = rand32() % cycle_len - start_time = r # side effect как в sub_100031F0 - - k = find_segment(keys, r) # t_start <= r < t_end - kn = 0 if (k + 1 == len(keys)) else (k + 1) - t0, t1 = keys[k][1], keys[k][2] - alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0) - return (k, kn), alpha -``` +- [Material (`MAT0`)](material.md) +- [Wear table (`WEAR`)](wear.md) +- [Texture (`Texm`)](texture.md) +- [Render pipeline](render.md) diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md index ccfac35..8aa2796 100644 --- a/docs/specs/msh-animation.md +++ b/docs/specs/msh-animation.md @@ -1,517 +1,112 @@ # MSH animation -Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor). +`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз. -Связанные документы: -- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`. -- [NRes / RsLi](nres.md) — контейнер и атрибуты записей. +Связанные страницы: ---- +- [MSH core](msh-core.md) +- [Render pipeline](render.md) -## 1. Область и источники +## 1. Ресурсы анимации -Спецификация основана на: -- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`. -- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений. -- `tmp/disassembler1/Ngi32.dll.c` (псевдо-C): `sub_10002F90`, `sub_10014540`, `sub_10014630`, `sub_10015D80`, `sub_10017E60`, `sub_10017F50`, `sub_10006D00`, `niGetProcAddress`. -- `tmp/disassembler2/Ngi32.dll.asm` (ASM): подтверждение таблицы `g_FastProc` и FPU control-word setup. -- валидации corpus (`testdata`): 435 моделей `*.msh`. - -Ниже разделено на: -- **Нормативно**: обязательно для runtime-совместимости. -- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor. - ---- - -## 2. Ресурсы и поля модели - -### 2.1. Res8 — key pool (нормативно) - -`Res8` — массив ключей фиксированного шага 24 байта. +### 1.1. `Res8` (пул ключей) ```c struct AnimKey24 { - float pos_x; // +0x00 - float pos_y; // +0x04 - float pos_z; // +0x08 - float time; // +0x0C - int16_t qx; // +0x10 - int16_t qy; // +0x12 - int16_t qz; // +0x14 - int16_t qw; // +0x16 + float pos_x; + float pos_y; + float pos_z; + float time; + int16_t qx; + int16_t qy; + int16_t qz; + int16_t qw; }; ``` -Декодирование quaternion-компонент: +Декодирование quaternion-компонент: `q = s16 / 32767.0`. + +### 1.2. `Res19` (карта кадров) ```c -float q = (float)s16 * (1.0f / 32767.0f); +uint16_t map_words[]; // size/2 элементов ``` -Атрибуты NRes: -- `attr1 = size / 24` (количество ключей). -- `attr2 = 0` (в observed corpus). -- `attr3 = 4` (не stride; это фактический runtime-инвариант формата). +`Res19.attr2` хранит глобальную длину таймлайна (число кадров). -### 2.2. Res19 — frame->segment map (нормативно) +### 1.3. Связь с `Res1` -`Res19` — непрерывный `uint16` массив: +Для каждого узла: -```c -uint16_t map_words[]; // count = size / 2 -``` +- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`. +- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`. -Атрибуты NRes: -- `attr1 = size / 2` (число `uint16` слов). -- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах). -- `attr3 = 2`. +## 2. Сэмплирование узла -### 2.3. Связь с Res1 node header (нормативно) +Вход: время `t`, текущий узел. +Выход: `quat(w,x,y,z)` и `pos(x,y,z)`. -Для `Res1` со stride 38 (основной формат): -- `hdr2` (`node + 0x04`) = `mapStart` (`0xFFFF` => map для узла отсутствует). -- `hdr3` (`node + 0x06`) = `fallbackKeyIndex` (индекс ключа в `Res8`). +### 2.1. Индекс кадра -Runtime читает эти поля напрямую в `sub_10012880`. +Движок использует x87-совместимое округление для выражения `t - 0.5`. +Для 1:1 повторения нужно сохранить ту же политику плавающей точки. -### 2.4. Поля runtime-модели, задействованные анимацией (нормативно) +### 2.2. Выбор key index -Инициализация в `sub_10015FD0`: -- `model+0x18` -> `Res8` pointer. -- `model+0x1C` -> `Res19` pointer. -- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`). +1. Если кадр вне диапазона `frame_count` -> `fallback_key`. +2. Если `anim_map_start == 0xFFFF` -> `fallback_key`. +3. Иначе берётся `map_words[anim_map_start + frame]`: + - если значение `>= fallback_key`, тоже используется `fallback_key`; + - иначе используется значение из map. ---- +### 2.3. Интерполяция -## 3. Runtime-сэмплирование узла (`sub_10012880`) +Если выбран fallback, возвращается ровно этот ключ без интерполяции. -Функция возвращает: -- quaternion (4 float) в буфер `outQuat`, -- позицию (3 float) в `outPos`. +Иначе: -Вход: -- `t` — sample time. -- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента). +1. Берутся соседние ключи `k0` и `k1`. +2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ. +3. Иначе: + - `alpha = (t - k0.time) / (k1.time - k0.time)` + - `pos = lerp(k0.pos, k1.pos, alpha)` + - `quat = slerp_like(k0.quat, k1.quat, alpha)` -### 3.1. Вычисление frame index (нормативно) +Кватернион в runtime хранится в порядке `[w, x, y, z]`. -Алгоритм: -1. `x = t - 0.5`. -2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер). +## 3. Смешивание двух сэмплов -Важно: -- это не «просто floor»; -- поведение зависит от x87 control word. +При blending между позами A и B: -В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`: -- `cw = (cw & 0xF0FF) | 0x003F`; -- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений. +1. Выбираются валидные стороны по `blend` и валидности времени. +2. Если активна одна сторона, берётся она. +3. Если активны обе: + - применяется shortest-path flip для `qB`; + - выполняется quaternion blend; + - позиция смешивается линейно. -Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент. +Матрица строится из quaternion, а translation подставляется отдельным шагом. -### 3.2. Выбор `keyIndex` (нормативно) +## 4. Каноника writer -```c -node = Res1 + nodeIndex * 38; -mapStart = u16(node + 4); // hdr2 -fallback = u16(node + 6); // hdr3 +Рекомендуемые правила: -if ((uint32_t)frame >= animFrameCount - || mapStart == 0xFFFF - || map_words[mapStart + (uint32_t)frame] >= fallback) { - keyIndex = fallback; -} else { - keyIndex = map_words[mapStart + (uint32_t)frame]; -} -``` +1. Ключи узлов писать подряд в `Res8` в порядке узлов. +2. `fallback_key` узла указывает на последний ключ его трека. +3. Для узлов с map выделять блок длины `frame_count` в `Res19`. +4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`. +5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`. +6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`. -Критично: -- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB. +## 5. Валидация перед сохранением -### 3.3. Сэмплирование ключей (нормативно) +- `Res8.size % 24 == 0` +- `Res19.size % 2 == 0` +- каждый `fallback_key < key_count` +- для узла с map: `anim_map_start + frame_count <= map_word_count` +- внутри трека времена ключей строго возрастают -`k0 = Res8[keyIndex]`. +## 6. Статус валидации -Ветки: -1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1`). -2. map-ветка: - - если `t == k0.time` -> вернуть `k0`; - - иначе берётся `k1 = Res8[keyIndex + 1]`; - - если `t == k1.time` -> вернуть `k1`; - - иначе: - - `alpha = (t - k0.time) / (k1.time - k0.time)`; - - `pos = lerp(k0.pos, k1.pos, alpha)`; - - `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]`). - -Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon. - -### 3.4. Порядок quaternion-компонент в runtime (нормативно) - -В `Res8` компоненты лежат как `qx,qy,qz,qw`, но в runtime-буферы они попадают в порядке: -- `outQuat[0] = qw`; -- `outQuat[1] = qx`; -- `outQuat[2] = qy`; -- `outQuat[3] = qz`. - -То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`. - ---- - -## 4. Runtime-смешивание двух сэмплов (`sub_10012560`) - -`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы. - -### 4.1. Валидация входов (нормативно) - -Выбор доступных сэмплов: -- `hasA = (blend < 1.0f) && (tA >= 0.0f)`. -- `hasB = (blend > 0.0f) && (tB >= 0.0f)`. - -Ветки: -- только `hasA`: матрица из A. -- только `hasB`: матрица из B. -- оба: полноценное смешивание. -- ни одного: в оригинале путь не защищён (caller contract). - -### 4.2. Смешивание quaternion (нормативно) - -Перед интерполяцией выполняется shortest-path flip: - -```c -if (|qA + qB|^2 < |qA - qB|^2) { - qB = -qB; -} -``` - -Далее: -- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]`); -- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]`). - -### 4.3. Смешивание translation (нормативно) - -Позиция смешивается отдельно: - -```c -pos = (1-blend) * posA + blend * posB; -outMatrix[3] = pos.x; -outMatrix[7] = pos.y; -outMatrix[11] = pos.z; -``` - -(`sub_1000B8E0` подтверждает, что используются именно эти ячейки). - -### 4.4. Точные `g_FastProc[14/17/22]` (нормативно) - -`niGetProcAddress(i)` в `Ngi32` возвращает `g_FastProc[i]` (таблица function pointers). -В `AniMesh` используются: -- `call [g_FastProc + 0x38]` -> index 14 -> `quat_to_matrix`. -- `call [g_FastProc + 0x44]` -> index 17 -> `quat_interp`. -- `call [g_FastProc + 0x58]` -> index 22 -> `quat_blend`. - -Связь с символами `Ngi32` (по адресам таблицы): -- `g_FastProc` base = `0x1003A058`; -- index 14 -> `0x1003A090`; -- index 17 -> `0x1003A09C`; -- index 22 -> `0x1003A0B0`. - -Назначения по CPU-веткам (`sub_10002F90`) и семантика: -- scalar path: `14=sub_10017E60` (или `sub_10014540`), `17=22=sub_10017F50` (или `sub_10014630`); -- SIMD path (`dword_1003A168`): `14=sub_1001D830`, `17=22=sub_10015D80`; -- все варианты эквивалентны по математике. - -Точная формула `quat_to_matrix` для `q=[w,x,y,z]`: - -```c -m[0] = 1 - 2*(y*y + z*z); -m[1] = 2*(x*y + w*z); -m[2] = 2*(x*z - w*y); -m[3] = 0; - -m[4] = 2*(x*y - w*z); -m[5] = 1 - 2*(x*x + z*z); -m[6] = 2*(y*z + w*x); -m[7] = 0; - -m[8] = 2*(x*z + w*y); -m[9] = 2*(y*z - w*x); -m[10] = 1 - 2*(x*x + y*y); -m[11] = 0; - -m[12] = 0; -m[13] = 0; -m[14] = 0; -m[15] = 1; -``` - -Точная формула `quat_interp`/`quat_blend` (`index 17` и `22`, один и тот же алгоритм): - -```c -float dot = dot4(q0, q1); -float sign = 1.0f; -if (dot < 0.0f) { dot = -dot; sign = -1.0f; } - -float w0, w1; -if (1.0f - dot <= 9.9999997e-6f) { - w0 = 1.0f - a; - w1 = a; -} else { - float theta = acos(dot); - float inv_sin_theta = 1.0f / sin(theta); - w1 = sin(a * theta) * inv_sin_theta; - w0 = cos(a * theta) - w1 * dot; -} -w1 *= sign; -out = w0 * q0 + w1 * q1; -``` - -Примечание: явной нормализации `out` в конце нет; используется закрытая форма SLERP-весов. - -Reference pseudocode: - -```c -void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) { - bool hasA = (blend < 1.0f) && (tA >= 0.0f); - bool hasB = (blend > 0.0f) && (tB >= 0.0f); - - float qA[4], qB[4], pA[3], pB[3]; - if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA); - if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB); - - if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; } - if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; } - // !hasA && !hasB: undefined by design, caller does not use this path. - - if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB); - float q[4]; - fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22] - quat_to_matrix(q, out_m); // g_FastProc[14] - - float p[3]; - p[0] = (1.0f - blend) * pA[0] + blend * pB[0]; - p[1] = (1.0f - blend) * pA[1] + blend * pB[1]; - p[2] = (1.0f - blend) * pA[2] + blend * pB[2]; - out_m[3] = p[0]; - out_m[7] = p[1]; - out_m[11] = p[2]; -} -``` - ---- - -## 5. Каноническая модель данных для toolchain - -Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты. - -### 5.1. Декомпозиция key pool на track-и узлов (канонично) - -Для `Res1` stride 38: -- `fallback_i = node[i].hdr3`. -- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`. -- track узла `i` = `Res8[start_i .. fallback_i]`. - -Наблюдаемые инварианты: -- `fallback_i` строго возрастает по `i`. -- track всегда непустой (`fallback_i >= start_i`). -- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ. -- для узлов с map track длиной минимум 2 ключа. - -### 5.2. Временная ось ключей (канонично) - -В observed corpus: -- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`). -- внутри track: строго возрастают. -- `time(start_i) == 0.0` у каждого узла. -- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`. - -### 5.3. Компоновка Res19 map-блоков (канонично) - -Если `Res19.size > 0`: -- map-блоки есть только у узлов с `hdr2 != 0xFFFF`; -- длина блока каждого такого узла: `frameCount = Res19.attr2`; -- блоки идут подряд, без дыр и overlap; -- итог: `Res19.attr1 == animated_node_count * frameCount`. - -Если модель статическая: -- `Res19.size == 0`, `Res19.attr1 == 0`, `Res19.attr2 == 1`, `Res19.attr3 == 2`; -- у всех узлов `hdr2 == 0xFFFF`. - -### 5.4. Семантика `map_words[f]` в каноничном writer - -Для кадра `f` и track `keys[start..end]`: -- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end`; -- иначе писать индекс левого ключа сегмента (`start <= idx < end`) такого, что: - - `keys[idx].time <= f < keys[idx+1].time`. - -В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`). - ---- - -## 6. Reference IR для редактора/конвертера - -Рекомендуемое промежуточное представление: - -```c -struct NodeAnimTrack { - uint32_t node_index; - bool has_map; // hdr2 != 0xFFFF - uint16_t fallback_key; // hdr3 (derived on write) - vector keys; // local keys for node - vector frame_map; // optional, size == frame_count when has_map -}; - -struct AnimModel { - uint32_t frame_count; // Res19.attr2 - vector tracks; // in node order -}; -``` - -Где `AnimKey`: -- `pos: float3`, -- `time: float`, -- `quat_raw: int16[4]` (для lossless), -- `quat_decoded: float4` (опционально для API/UI). - ---- - -## 7. Алгоритм чтения (reader) - -1. Загрузить `Res1`, `Res8`, `Res19`. -2. Проверить `Res8.size % 24 == 0`, `Res19.size % 2 == 0`. -3. Для каждого узла `i` (stride 38): - - взять `hdr2/hdr3`; - - вычислить `start_i` через предыдущий `hdr3`; - - извлечь `keys[start_i..hdr3]`; - - если `hdr2 != 0xFFFF`, взять `frame_map = Res19[hdr2 : hdr2 + frame_count]`. -4. Валидировать, что map-значения либо `< hdr3`, либо fallback (`== hdr3` канонично). - ---- - -## 8. Алгоритм записи (writer) - -Нормативный минимум для runtime-совместимости: - -1. Собрать keys всех узлов в один `Res8` pool в node-order. -2. Записать `hdr3 = end_index` каждого узла. -3. Вычислить `frame_count` и записать в `Res19.attr2`. -4. Для узлов с map: - - `hdr2 = cursor`; - - append `frame_count` слов в `Res19`; - - `cursor += frame_count`. -5. Для узлов без map: `hdr2 = 0xFFFF`. -6. Выставить атрибуты: - - `Res8.attr1 = key_count`, `Res8.attr2 = 0`, `Res8.attr3 = 4`; - - `Res19.attr1 = map_word_count`, `Res19.attr3 = 2`. - -Каноничный writer (рекомендуется): -- генерирует map по правилу §5.4; -- fallback-фреймы записывает `== fallback`; -- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`). - ---- - -## 9. Валидация перед сохранением - -Обязательные проверки: - -1. `Res8.size % 24 == 0`, `Res19.size % 2 == 0`. -2. Для каждого узла: `fallbackKeyIndex < key_count`. -3. Если `hdr2 != 0xFFFF`: `hdr2 + frame_count <= map_word_count`. -4. Для map-сегмента узла: - - любое значение `< fallback` должно удовлетворять `value + 1 < key_count`. -5. В track узла: - - `time` строго возрастает; - - при наличии map минимум 2 ключа. -6. `frame_count > 0` (игровые ассеты используют минимум 1). - -Рекомендуемые проверки (каноничность): - -1. `fallback_i` строго возрастает по узлам. -2. track каждого узла начинается с `time == 0`. -3. `frame_count == max_end_time + 1`. -4. map-блоки узлов без дыр/overlap. - ---- - -## 10. Edge cases и совместимость - -### 10.1. `Res19.size == 0` - -Поддерживается runtime-ом: -- `frame_count` обычно 1; -- `hdr2 == 0xFFFF` у всех узлов; -- сэмплирование всегда через fallback key (`hdr3`). - -### 10.2. Узлы без map - -Это нормальный режим для статических/квазистатических узлов: -- `hdr2 = 0xFFFF`; -- `hdr3` указывает на единственный ключ узла (канонично). - -### 10.3. `Res1.attr3 == 24` (legacy outlier) - -В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`): -- `Res1.attr3 = 24`; -- `Res8` содержит 1 ключ; -- `Res19.size == 0`. - -Алгоритм `sub_10012880` адресует node как stride 38, поэтому этот случай нельзя интерпретировать правилами текущего 38-byte формата. Практически это отдельный legacy-формат/legacy-path вне описанного runtime-контракта. - -### 10.4. Квантование quaternion при экспорте - -Для новых данных: -- используйте `round(q * 32767)`; -- clamp к `[-32767, 32767]` (каноничный диапазон ассетов). - ---- - -## 11. Reference pseudocode (1:1 runtime path) - -```c -void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) { - Node38 *node = (Node38 *)((uint8_t *)m->res1 + node_idx * 38); - uint16_t map_start = node->hdr2; - uint16_t fallback = node->hdr3; - uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2 - - int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path - - uint16_t key_idx; - if ((uint32_t)frame >= frame_cnt || - map_start == 0xFFFF || - m->res19[map_start + (uint32_t)frame] >= fallback) { - key_idx = fallback; - decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos); - return; - } - - key_idx = m->res19[map_start + (uint32_t)frame]; - AnimKey24 *k0 = &m->res8[key_idx]; - if (t == k0->time) { - decode_key_quat_pos(k0, out_quat, out_pos); - return; - } - - AnimKey24 *k1 = &m->res8[key_idx + 1]; - if (t == k1->time) { - decode_key_quat_pos(k1, out_quat, out_pos); - return; - } - - float a = (t - k0->time) / (k1->time - k0->time); - out_pos[0] = lerp(k0->pos_x, k1->pos_x, a); - out_pos[1] = lerp(k0->pos_y, k1->pos_y, a); - out_pos[2] = lerp(k0->pos_z, k1->pos_z, a); - fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17] -} -``` - -## 12. Границы полноты - -Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor). - -Единственный подтверждённый неполный сегмент: -- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса. - -Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно: -- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках); -- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов. +- Форматные проверки включены в `tools/msh_doc_validator.py`. +- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся. diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md index a80496a..6a33049 100644 --- a/docs/specs/msh-core.md +++ b/docs/specs/msh-core.md @@ -1,678 +1,178 @@ # MSH core -Документ фиксирует core-часть формата MSH на уровне, достаточном для: +`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели. +Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии. -- реализации runtime-совместимого движка (поведение 1:1); -- реализации reader/writer/editor/converter с lossless round-trip; -- валидации ассетов и диагностики повреждений. +Связанные страницы: -Связанные документы: +- [MSH animation](msh-animation.md) +- [Material](material.md) +- [Texture (Texm)](texture.md) +- [Render pipeline](render.md) +- [NRes / RsLi](nres.md) -- [NRes / RsLi](nres.md) — контейнер, каталог, атрибуты, выравнивание. -- [MSH animation](msh-animation.md) — детальная спецификация `Res8`/`Res19`. -- [Materials + Texm](materials-texm.md) — материальная часть и текстуры. -- [Terrain + map loading](terrain-map-loading.md) — отдельная ветка terrain-ресурсов. +## 1. Общая модель ---- +MSH-модель хранится как `NRes`-контейнер. +Связь таблиц строится по `type`, а не по порядку записей. -## 1. Область и источники +Базовый путь геометрии: -### 1.1. Что покрывает этот документ +1. `Res1` выбирает slot по `(node, lod, group)`. +2. `Res2.slot` задаёт диапазоны треугольников и батчей. +3. `Res13` задаёт диапазон индексов и `baseVertex`. +4. `Res6` даёт `uint16` индексы. +5. `Res3/Res4/Res5` дают вершины, нормали и UV. -Этот документ покрывает именно **core-геометрию и её runtime-связи**: +## 2. Карта core-ресурсов -- `Res1` (node table), -- `Res2` (header + slots), -- `Res3/4/5` (позиции/нормали/UV0), -- `Res6` (индексы), -- `Res7` (triangle descriptors), -- `Res10` (node string table), -- `Res13` (batch table), -- optional `Res15/16/18/20`, -- точки стыка с анимацией (`Res8/Res19`). - -### 1.2. Что не покрывает - -- детальную семантику материалов/текстурных фаз (см. `materials-texm.md`), -- terrain-ветку (`type 11/14/21` и связанные структуры, см. `terrain-map-loading.md`), -- полную математику анимационного сэмплирования (см. `msh-animation.md`). - -### 1.3. Источники реверса - -Основные подтверждения: - -- `tmp/disassembler1/AniMesh.dll.c`: - - `sub_10015FD0` (загрузка ресурсов core-модели), - - `sub_100124D0` (поиск slot по node/lod/group), - - `sub_10012530` (доступ к строке узла в `Res10`), - - `sub_1000B2C0`/`sub_10013680` (tri/batch path), - - `sub_1000A460` (инициализация runtime-инстансов, копирование глобальных bounds). -- `tmp/disassembler2/AniMesh.dll.asm` — подтверждение смещений/stride/ветвлений. -- валидация corpus: `testdata/nres` (435 MSH моделей, нулевые ошибки в `tools/msh_doc_validator.py`). - ---- - -## 2. Модель данных MSH (high-level) - -MSH-модель — это NRes-контейнер, где ресурсы связаны **не по порядку, а по type-id**. - -Базовая связь таблиц: - -1. `Res1` для `(node, lod, group)` выбирает `slotIndex`. -2. `Res2.slot[slotIndex]` даёт диапазоны triangle/batch (`triStart/triCount`, `batchStart/batchCount`). -3. `Res13.batch` даёт `indexStart/indexCount/baseVertex`. -4. `Res6` даёт сырые `uint16` индексы. -5. `Res3/4/5` дают vertex-атрибуты по `baseVertex + index`. - -Ключевая особенность runtime: - -- скиннинг по узлам жёсткий (rigid attachment), без per-vertex bone weights в core-ресурсах. - ---- - -## 3. Карта ресурсов и границы core - -### 3.1. Ресурсы, которые читает core-loader (`sub_10015FD0`) - -| Type | Ресурс | Статус в core-loader | Формат/stride | +| Type | Ресурс | Обязательность | Stride / layout | |---:|---|---|---| -| 1 | Node table | required | 38 байт/узел (основной случай) | -| 2 | Model header + slots | required | `0x8C + slotCount*0x44` | -| 3 | Positions | required | 12 | -| 4 | Packed normals | обычно required | 4 | -| 5 | Packed UV0 | обычно required | 4 | -| 6 | Index buffer | required | 2 | -| 7 | Triangle descriptors | обычно required | 16 | -| 8 | Anim key pool | optional для статических | 24 | -| 10 | String table | обычно required | variable | -| 13 | Batch table | required | 20 | -| 15 | Доп. stream | optional | 8 | -| 16 | Tangent/bitangent stream | optional | 8 | -| 18 | Vertex color stream | optional | 4 | -| 19 | Anim mapping | optional для статических | 2 | -| 20 | Доп. таблица | optional | variable | +| 1 | Node table | обязательный | обычно 38 байт | +| 2 | Header + slots | обязательный | `0x8C + n*68` | +| 3 | Positions | обязательный | 12 | +| 4 | Packed normals | обычно обязательный | 4 | +| 5 | Packed UV0 | обычно обязательный | 4 | +| 6 | Index buffer | обязательный | 2 | +| 7 | Tri descriptors | для коллизии/пикинга | 16 | +| 8 | Anim key pool | для анимированных | 24 | +| 10 | Node strings | опциональный | variable | +| 13 | Batch table | обязательный | 20 | +| 15 | Доп. stream | опциональный | 8 | +| 16 | Доп. stream | опциональный | 8 | +| 18 | Доп. stream | опциональный | 4 | +| 19 | Anim map | для анимированных | 2 | +| 20 | Доп. таблица | опциональный | variable | -### 3.2. Ресурсы, которые встречаются в MSH, но вне этого документа +## 3. Основные структуры -В corpus из 435 моделей стабильно встречаются также `type 9` и `type 17`. -Они **не загружаются** `sub_10015FD0` и относятся к некоревым подсистемам (материалы/эффекты/прочие runtime-ветки). - -### 3.3. Прямая MSH и вложенная MSH - -Tooling должен поддерживать два режима входа: - -- файл уже является модельным NRes (`magic NRes` и содержит `type 1/2/3/6/13`), -- файл-архив содержит `.msh` entry, внутри которой вложенный NRes модели. - ---- - -## 4. Runtime-контракт загрузки (`sub_10015FD0`) - -`sub_10015FD0` заполняет структуру модели размером `0xA4` байт и строит derived pointers/stride. - -### 4.1. Порядок `find/open` - -Фактический порядок загрузки: - -1. `type 1 -> this+0x00` -2. `type 2 -> this+0x04` -3. `type 3 -> this+0x0C` -4. `type 4 -> this+0x10` -5. `type 5 -> this+0x14` -6. `type 10 -> this+0x20` -7. `type 8 -> this+0x18` -8. `type 19 -> this+0x1C` -9. `type 7 -> this+0x24` -10. `type 13 -> this+0x28` -11. `type 6 -> this+0x2C` -12. `type 15 -> this+0x34` -13. `type 16 -> this+0x38` -14. `type 18 -> this+0x64` (через отдельный `find`, optional) -15. `type 20 -> this+0x30` (optional) - -### 4.2. Derived-поля (стримы) - -После загрузки ставятся derived-поля: - -- `this+0x08 = Res2 + 0x8C` (начало slot table), -- `this+0x3C = Res3`, `this+0x40 = 12`, -- `this+0x44 = Res4`, `this+0x48 = 4`, -- `this+0x5C = Res5`, `this+0x60 = 4`, -- `this+0x8C = Res15`, `this+0x90 = 8`, -- `this+0x94 = 0` (инициализация нулём). - -Для `Res16`: - -- если есть: `this+0x4C = Res16`, `this+0x50 = 8`, `this+0x54 = Res16+4`, `this+0x58 = 8`; -- если нет: `this+0x4C = 0`, `this+0x54 = 0` (stride остаются несущественными, т.к. указатели нулевые). - -Для `Res18`: - -- если найден: `this+0x64 = ptr`, `this+0x68 = 4`; -- иначе: `this+0x64 = 0`, `this+0x68 = 0`. - -### 4.3. Метаданные из каталога NRes - -- `this+0x9C` получает `entry(type19).attr2` (читается из поля `+8` каталожной записи, индекс `entry * 64`). -- `this+0xA0` получает `entry(type20).attr1` (поле `+4`) только если `type20` существует и успешно открыт; иначе `0`. - ---- - -## 5. Бинарные структуры core-ресурсов - -Все структуры little-endian. - -### 5.1. `Res1` — Node table - -Базовый stride: `38` байт (`19 * uint16`). +### 3.1. `Res1` (узлы) ```c struct Node38 { - uint16_t hdr0; // +0 - uint16_t hdr1; // +2 - uint16_t hdr2; // +4 - uint16_t hdr3; // +6 - uint16_t slotIndex[15]; // +8: [lod0 g0..g4][lod1 g0..g4][lod2 g0..g4] + uint16_t hdr0; + uint16_t parent_or_link; + uint16_t anim_map_start; + uint16_t fallback_key; + uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4 }; ``` -#### Подтверждённые поля - -- `hdr1`: parent/index-link (используется при построении инстанса), `0xFFFF` = нет. -- `hdr2`: `mapStart` для `Res19` (см. `msh-animation.md`), `0xFFFF` = нет map. -- `hdr3`: fallback key index в `Res8`. -- `hdr0`: node flags (есть битовые проверки, но полная доменная семантика не закрыта). - -#### Адресация slot (runtime-функция `sub_100124D0`) +Формула slot-выбора: ```c -uint16_t get_slot_index(const Node38* node_table, uint32_t nodeIndex, int lod, int group, int current_lod) { - int use_lod = (lod == -1) ? current_lod : lod; - int word_index = 4 + (int)nodeIndex * 19 + use_lod * 5 + group; - return *(uint16_t*)((const uint8_t*)node_table + word_index * 2); -} +slot = node.slotIndex[lod * 5 + group] ``` -`0xFFFF` означает "слот отсутствует". +`0xFFFF` означает отсутствие слота. -#### Вариант stride=24 - -В corpus есть единичный служебный outlier с `Res1.attr3 = 24`. -Для 1:1 editing существующих ассетов требуется copy-through этого варианта. -Новая генерация должна ориентироваться на stride `38`, если нет чёткой цели поддержать legacy-вариант. - ---- - -### 5.2. `Res2` — Model header + Slot table - -``` -Res2: - [0x00 .. 0x8B] model header (140 bytes) - [0x8C .. end] slot records (68 bytes each) -``` - -#### 5.2.1. Header (0x8C) - -Runtime копирует блоки как float-массивы: - -- `0x00..0x5F` (`24 float`) — глобальный hull (`vec3[8]`), -- `0x60..0x6F` (`4 float`) — глобальная sphere (`center.xyz + radius`), -- `0x70..0x8B` (`7 float`) — сегмент/капсула (`A.xyz`, `B.xyz`, `radius`). - -#### 5.2.2. Slot record (68 bytes) +### 3.2. `Res2` (header + slot records) ```c struct Slot68 { - uint16_t triStart; // +0 - uint16_t triCount; // +2 - uint16_t batchStart; // +4 - uint16_t batchCount; // +6 - - float aabbMin[3]; // +8 - float aabbMax[3]; // +20 - float sphereCenter[3]; // +32 - float sphereRadius; // +44 - - uint32_t unk30; // +48 - uint32_t unk34; // +52 - uint32_t unk38; // +56 - uint32_t unk3C; // +60 - uint32_t unk40; // +64 + uint16_t triStart; + uint16_t triCount; + uint16_t batchStart; + uint16_t batchCount; + float aabbMin[3]; + float aabbMax[3]; + float sphereCenter[3]; + float sphereRadius; + uint32_t opaque[5]; }; ``` -`triCount` подтверждён как длина диапазона: +`opaque[5]` должны сохраняться 1:1. -```c -triId >= triStart && triId < triStart + triCount -``` +### 3.3. `Res3`, `Res4`, `Res5`, `Res6` -Хвост `unk30..unk40` должен сохраняться без изменений в editor/writer. - -#### 5.2.3. Bounds semantics - -- Slot bounds локальны относительно узла. -- При world-трансформации sphere radius масштабируется по `max(scaleX, scaleY, scaleZ)` при неравномерном scale. - ---- - -### 5.3. `Res3` — Positions - -```c -struct Position12 { - float x; - float y; - float z; -}; -``` - -Stride `12`. - ---- - -### 5.4. `Res4` — Packed normals - -```c -struct PackedNormal4 { - int8_t nx; - int8_t ny; - int8_t nz; - int8_t nw; // семантика 4-го байта не зафиксирована -}; -``` +- `Res3`: `float3` позиции (`stride=12`) +- `Res4`: `int8[4]` packed normal (`stride=4`) +- `Res5`: `int16[2]` UV (`stride=4`) +- `Res6`: `uint16` индексы (`stride=2`) Декодирование: -```c -normal = clamp((float)n / 127.0f, -1.0f, 1.0f) -``` +- normal = `clamp(n / 127.0, -1..1)` +- uv = `packed / 1024.0` -- делитель строго `127.0`; -- clamp обязателен из-за `-128 / 127.0`. - -Кодирование (writer): - -```c -int8_t q = (int8_t)clamp(round(v * 127.0f), -128, 127); -``` - ---- - -### 5.5. `Res5` — Packed UV0 - -```c -struct PackedUV4 { - int16_t u; - int16_t v; -}; -``` - -Декодирование: - -```c -uv = packed / 1024.0f -``` - -Кодирование: - -```c -int16_t q = (int16_t)clamp(round(uv * 1024.0f), -32768, 32767); -``` - ---- - -### 5.6. `Res6` — Index buffer - -Массив `uint16`, stride `2`. - -Runtime-путь: - -```c -vertexIndex = Res6[indexStart + i] + batch.baseVertex; -``` - -`indexStart` хранится в элементах, не в байтах. - ---- - -### 5.7. `Res7` — Triangle descriptors (16 bytes) +### 3.4. `Res7` и `Res13` ```c struct TriDesc16 { - uint16_t triFlags; // +0 - uint16_t linkTri0; // +2 - uint16_t linkTri1; // +4 - uint16_t linkTri2; // +6 - int16_t nX; // +8 - int16_t nY; // +10 - int16_t nZ; // +12 - uint16_t selPacked; // +14 + uint16_t triFlags; + uint16_t link0; + uint16_t link1; + uint16_t link2; + int16_t nx; + int16_t ny; + int16_t nz; + uint16_t selPacked; }; -``` -- `nX/nY/nZ` декодируются через `1/32767`. -- `linkTri*` используются в tri-neighbour/collision path. - -Раскладка `selPacked` (3 селектора по 2 бита): - -```c -sel0 = (selPacked >> 0) & 0x3; if (sel0 == 3) sel0 = 0xFFFF; -sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF; -sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF; -``` - ---- - -### 5.8. `Res13` — Batch table (20 bytes) - -```c struct Batch20 { - uint16_t batchFlags; // +0 - uint16_t materialIndex; // +2 - uint16_t unk4; // +4 - uint16_t unk6; // +6 - uint16_t indexCount; // +8 - uint32_t indexStart; // +10 - uint16_t unk14; // +14 - uint32_t baseVertex; // +16 + uint16_t batchFlags; + uint16_t materialIndex; + uint16_t opaque4; + uint16_t opaque6; + uint16_t indexCount; + uint32_t indexStart; + uint16_t opaque14; + uint32_t baseVertex; }; ``` -`unk4/unk6/unk14` семантически не закрыты; writer/editor должны сохранять. +`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`. ---- +## 4. Runtime-обход модели -### 5.9. `Res10` — Node string table - -Последовательность записей variable-length: - -```c -struct Res10Record { - uint32_t len; // длина строки без '\0' - char text[]; // если len>0: len+1 байт (с '\0'), иначе payload нет -}; -``` - -Переход: - -```c -next = cur + 4 + (len ? len + 1 : 0); -``` - -`sub_10012530` возвращает: - -- `NULL`, если `len == 0`, -- `record + 4`, если `len > 0`. - -Индекс записи в `Res10` соответствует `nodeIndex`. - ---- - -### 5.10. Optional streams - -#### `Res15` (stride 8) - -Дополнительный поток на вершину (семантика не полностью подтверждена). - -#### `Res16` (stride 8, split 2x4) - -Runtime делит поток на два interleaved подпотока: - -- stream A: `base+0`, stride 8, -- stream B: `base+4`, stride 8. - -В corpus из `testdata/nres` этот ресурс не встретился, но loader поддерживает. - -#### `Res18` (stride 4) - -Vertex color / доп. packed-канал. В corpus встречается на части моделей. - -#### `Res20` - -Доп. таблица неизвестной доменной семантики. Loader хранит pointer и метаданные каталога (`attr1`). - ---- - -### 5.11. Точки стыка с анимацией (`Res8`/`Res19`) - -Core-loader загружает: - -- `Res8` в `this+0x18`, -- `Res19` в `this+0x1C`, -- `Res19.attr2` в `this+0x9C`. - -Полный runtime-алгоритм сэмплирования/смешивания описан в [MSH animation](msh-animation.md). - ---- - -## 6. Runtime-алгоритмы core - -### 6.1. Slot lookup (`sub_100124D0`) - -Вход: runtime-node-instance, `group`, `lod`. - -1. Если нет model pointer -> `NULL`. -2. `lod == -1` -> подставить `current_lod` инстанса. -3. Вычислить `slotIndex` через формулу `4 + node*19 + lod*5 + group`. -4. Если `slotIndex == 0xFFFF` -> `NULL`. -5. Иначе вернуть `Res2.slotBase + slotIndex * 68`. - -### 6.2. Node string lookup (`sub_10012530`) - -1. Идти по `Res10`-записям `nodeIndex` раз. -2. Возвращать `NULL` или `char*` по правилу `len==0`. - -### 6.3. Геометрический обход для рендера - -Reference-путь, эквивалентный runtime-логике: +Псевдокод рендера: ```c for each node: slot = resolve_slot(node, lod, group) - if (!slot) continue + if slot == none: continue - for b in [slot.batchStart .. slot.batchStart + slot.batchCount): - batch = Res13[b] - for i in [0 .. batch.indexCount): - idx = Res6[batch.indexStart + i] - vtx = batch.baseVertex + idx + if culled(slot.bounds, node_transform): continue - pos = Res3[vtx] - nrm = decode_res4(Res4[vtx]) - uv0 = decode_res5(Res5[vtx]) + for b in slot.batchRange: + batch = batches[b] + bind_material(batch.materialIndex) + + draw_indexed( + baseVertex = batch.baseVertex, + indexStart = batch.indexStart, + indexCount = batch.indexCount + ) ``` -### 6.4. Tri/collision path (обобщённо) +## 5. Критические инварианты -- `sub_1000B2C0` и `sub_10013680` используют tri-диапазоны слота + `Res7` link/select-поля. -- Для collision/picking-контекста должны быть валидны: - - `slot.triStart + slot.triCount <= triDescCount`, - - `linkTri*` либо `0xFFFF`, либо `< triDescCount`. +Обязательно проверять: ---- +- `Res2.size >= 0x8C` +- `(Res2.size - 0x8C) % 68 == 0` +- `batchStart + batchCount` не выходит за `Res13` +- `triStart + triCount` не выходит за `Res7` +- `indexStart + indexCount` не выходит за `Res6` +- `baseVertex + max(indexSlice) < vertexCount` +- `slotIndex == 0xFFFF` или `< slotCount` -## 7. Инварианты и валидация (reader) +## 6. Важные edge-cases -### 7.1. Базовые проверки целостности +- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through. +- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел. +- Неизвестные поля таблиц нельзя нормализовать или обнулять. -- каждый fixed-stride ресурс делится на stride без остатка; -- `Res2.size >= 0x8C`; -- `(Res2.size - 0x8C) % 68 == 0`; -- `Res2.attr1 == slotCount`, `Res2.attr3 == 68`; -- `Res3.attr3 == 12`, `Res4.attr3 == 4`, `Res5.attr3 == 4`, `Res6.attr3 == 2`, `Res7.attr3 == 16`, `Res13.attr3 == 20`; -- `Res8.attr3 == 4` (не stride), `Res19.attr3 == 2`, `Res10.attr3 == 0` (в observed assets). +## 7. Правила для writer/editor -### 7.2. Cross-table проверки +1. Сохранять неизвестные поля и неизвестные `type`-ресурсы. +2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля). +3. Не менять порядок/контент opaque-данных без явной цели. +4. Сериализовать little-endian, без внутреннего padding. -- `slot.batchStart + slot.batchCount <= batchCount`; -- `slot.triStart + slot.triCount <= triDescCount`; -- `batch.indexStart + batch.indexCount <= indexCount`; -- `batch.baseVertex + max(indexSlice) < vertexCount`; -- все `Res1.slotIndex[*]` либо `0xFFFF`, либо `< slotCount`; -- для `Res10`: парсинг ровно `nodeCount` записей без хвостовых байт; -- для `Res7.linkTri*`: либо `0xFFFF`, либо `< triDescCount`. +## 8. Статус валидации -### 7.3. Strict vs tolerant режим - -Рекомендуется 2 режима reader: - -- `strict`: любое нарушение инвариантов -> ошибка; -- `tolerant`: безопасно отбрасывать/игнорировать только локально повреждённые диапазоны (без OOB). - ---- - -## 8. Правила writer/editor - -### 8.1. Обязательная политика для 1:1 editing - -- сохранять неизвестные поля (`Slot68.unk*`, `Batch20.unk*`, `Node.hdr0` и т.д.) без модификации, если нет осознанного пересчёта; -- сохранять неизвестные resource types и их payload/атрибуты; -- не полагаться на порядок ресурсов в контейнере: lookup в runtime идёт по type-id. - -### 8.2. Пересчёт атрибутов каталога - -При записи изменённых ресурсов: - -- `attr1` = count (или форматно-специфичное значение), -- `attr2` — по формату/семантике ресурса, -- `attr3` — stride/константа формата. - -Практические правила для core: - -- `Res1`: `attr1=nodeCount`, `attr3=38` (или исходный вариант, если copy-through legacy), `attr2` лучше сохранять из исходника; -- `Res2`: `attr1=slotCount`, `attr2=0`, `attr3=68`; -- `Res3/4/5/6/7/13/15/16/18`: `attr1=size/stride`, `attr2=0`, `attr3=stride`; -- `Res8`: `attr1=size/24`, `attr3=4`; -- `Res10`: `attr1=nodeCount`, `attr2=0`, `attr3=0`; -- `Res19`: `attr1=size/2`, `attr2=frameCount`, `attr3=2`. - -### 8.3. Матрица зависимостей при редактировании - -| Операция | Какие ресурсы обновлять | -|---|---| -| Смещение/деформация вершин | `Res3`, при необходимости `Res4`, bounds в `Res2` | -| Изменение UV | `Res5` (и опционально `Res15`) | -| Изменение topology (индексы/треугольники) | `Res6`, `Res13`, `Res7`, диапазоны `Res2.slot` | -| Изменение LOD/group назначения | `Res1.slotIndex`, возможно `Res2.slot` | -| Изменение имени узла | `Res10` | -| Изменение иерархии/анимации узлов | `Res1.hdr1/hdr2/hdr3`, `Res8`, `Res19` | -| Добавление/удаление slot | `Res2`, ссылки из `Res1`, диапазоны batch/tri | - -### 8.4. Детерминированная сериализация - -- little-endian для всех чисел; -- без внутреннего padding в таблицах ресурсов; -- выравнивание блоков ресурсов в NRes по 8 байт (через контейнер). - ---- - -## 9. Рекомендованный canonical IR для toolchain - -Минимальный IR для безопасного round-trip: - -```c -struct ModelCoreIR { - // raw payloads for unknown/passthrough types - map raw_passthrough; - - vector nodes; // Res1 decoded (hdr + matrix) - Header140 header; // Res2[0x00..0x8B] - vector slots; // Res2 slot table (включая unk tail) - - vector positions; // Res3 - vector normals_raw; // Res4 raw + optional decoded cache - vector uv0_raw; // Res5 raw + optional decoded cache - - vector indices; // Res6 - vector tri; // Res7 - vector batches; // Res13 - vector> node_names; // Res10 - - optional> res15_raw; - optional> res16_raw; - optional> colors_raw; // Res18 - optional res20_raw; - - // animation bridge - optional> anim_keys; // Res8 - optional> anim_map_words; // Res19 - uint32_t anim_frame_count; -}; -``` - -Принцип: где семантика неполная, хранить raw и переизлучать байт-в-байт. - ---- - -## 10. Практика конвертации - -### 10.1. MSH -> OBJ/GLTF - -- `Res3` напрямую в позиции; -- `Res6 + Res13` в faces; -- нормали/UV декодировать через коэффициенты `1/127`, `1/1024`; -- при экспорте по LOD/group использовать `Res1` матрицу слотов, а не "все batch подряд" (если нужен runtime-эквивалент); -- пометить ограничения: core не содержит классический weight-скиннинг. - -### 10.2. Обратный импорт (OBJ/GLTF -> MSH) - -Для 1:1 ожидаемого поведения импортёр должен: - -- строить корректные `Res13` диапазоны, -- строить/обновлять `Res2.slot` ranges и bounds, -- поддерживать quantization при упаковке (`Res4/Res5`), -- сохранять unknown-поля таблиц, если вход был редактированием существующей модели. - ---- - -## 11. Наблюдения по corpus (testdata/nres) - -Сводка по 435 MSH-моделям: - -- валидны все 435/435 по `tools/msh_doc_validator.py`; -- основной порядок типов: - - `414`: `(1,2,3,4,5,15,13,6,7,8,19,9,10,17)` - - `21`: `(1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20)` -- `Res1.attr3`: `38` в 434 моделях, `24` в 1 модели; -- `Res18` и `Res20` встречаются в 21 модели; -- `Res16` в данном corpus не встретился; -- `Res8/Res19` присутствуют во всех моделях, но `Res19.attr2=1` часто соответствует статике. - ---- - -## 12. Открытые вопросы (не блокируют 1:1) - -- точная доменная семантика `Node.hdr0` битов; -- полные имена/назначения `Batch20.unk4/unk6/unk14`; -- назначение `Slot68.unk30..unk40`; -- полная семантика `Res15/Res16/Res18/Res20` payload beyond stride-level; -- точная семантика 4-го байта в `PackedNormal4`. - -Для runtime/reader/writer это не критично при условии byte-preserving policy. - ---- - -## 13. Чеклист реализации 1:1 - -### 13.1. Engine runtime - -- реализован loader-порядок как в `sub_10015FD0`; -- slot lookup по формуле `4 + node*19 + lod*5 + group`; -- декодирование `Res4` через `/127.0` с clamp; -- декодирование `Res5` через `/1024.0`; -- tri селекторы `selPacked` трактуются как 2-битные с `3 -> 0xFFFF`; -- корректная обработка `0xFFFF` sentinel во всех таблицах. - -### 13.2. Reader/validator - -- строгая проверка stride/размеров/диапазонов; -- OOB-защита всех индексных доступов; -- поддержка both direct-model и nested `.msh` payload. - -### 13.3. Writer/editor - -- стабильный пересчёт `attr1/attr2/attr3`; -- сохранение unknown fields и unknown resource types; -- детерминированная сериализация NRes (8-byte align); -- regression-проверка round-trip: `decode -> encode -> decode` без расхождений структуры/диапазонов. +- Инварианты формата реализованы в `tools/msh_doc_validator.py`. +- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся. diff --git a/docs/specs/msh.md b/docs/specs/msh.md index e2623f8..a4e29b6 100644 --- a/docs/specs/msh.md +++ b/docs/specs/msh.md @@ -6,11 +6,13 @@ 1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица. 2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция. -3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`. -4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока. -5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру. -6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре. -7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы. +3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация. +4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap. +5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`. +6. [FXID](fxid.md) — контейнер эффекта и поток команд. +7. [Render pipeline](render.md) — полный процесс рендера кадра. +8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру. +9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы. ## Связанные спецификации diff --git a/docs/specs/render.md b/docs/specs/render.md new file mode 100644 index 0000000..2994049 --- /dev/null +++ b/docs/specs/render.md @@ -0,0 +1,147 @@ +# Render pipeline + +Документ описывает полный процесс рендера кадра в движке Parkan: Iron Strategy, без привязки к внутренним адресам/именам дизассемблера. + +Связанные страницы: + +- [MSH core](msh-core.md) +- [MSH animation](msh-animation.md) +- [Material (`MAT0`)](material.md) +- [Wear table (`WEAR`)](wear.md) +- [Texture (`Texm`)](texture.md) +- [FXID](fxid.md) + +## 1. Инициализация рендера + +На старте движок: + +1. Выбирает видеодрайвер (software или аппаратный). +2. Создаёт render backend. +3. Подключает библиотеки ресурсов: + - `Material.lib` + - `Textures.lib` + - `LightMap.lib` + - `palettes.lib` +4. Инициализирует менеджеры: + - material manager + - texture/lightmap cache + - effect manager +5. Загружает базовые world-ресурсы (включая наборы объектов сцены). + +## 2. Структура кадра + +Кадр выполняется как последовательность: + +1. `Simulation update` +2. `Animation sampling` +3. `Visibility / culling` +4. `Material + texture resolve` +5. `Mesh draw` +6. `FX update + draw` +7. `UI/overlay draw` +8. `Present` + +## 3. Geometry path + +### 3.1. Подготовка инстансов + +Для каждого видимого объекта: + +1. Вычисляется `world transform`. +2. Выбирается `LOD`. +3. Для каждого узла выбирается slot через `Res1`. + +### 3.2. Culling + +Сначала отсекаются узлы/слоты по bounds (`AABB/sphere`) из `Res2`. + +### 3.3. Батчи + +Для каждого прошедшего slot: + +1. Берутся батчи из диапазона `Res13`. +2. По `materialIndex` выбирается активный материал. +3. По фазе материала выбирается текстура/lightmap. +4. Выполняется `DrawIndexedPrimitive`: + - индексный диапазон: `indexStart/indexCount` + - базовая вершина: `baseVertex` + - индексы читаются из `Res6` + - вершины/атрибуты читаются из `Res3/Res4/Res5` (+ optional streams) + +## 4. Animation path + +Для анимированных моделей: + +1. Для узла выбирается ключ через `Res19` и fallback-логику. +2. Декодируются `pos + quat` из `Res8`. +3. При необходимости выполняется blending двух сэмплов. +4. Узловая матрица передаётся в geometry path. + +## 5. Material path + +Material pipeline на кадре: + +1. По material handle выбирается запись `MAT0`. +2. По игровому времени выбирается текущая фаза. +3. Применяются коэффициенты фазы (цвет/альфа/параметры). +4. Резолвятся ссылки на texture/lightmap. +5. Невалидные ссылки обрабатываются fallback-стратегией. + +## 6. Texture path + +При резолве текстуры: + +1. Ищется `Texm` entry по имени. +2. Проверяется и декодируется заголовок. +3. При необходимости применяется `mipSkip`. +4. Для indexed-формата подключается палитра. +5. Optional `Page` chunk интерпретируется как atlas-таблица. +6. Объект текстуры кладётся/берётся из cache. + +## 7. FX path + +Эффекты выполняются параллельно mesh-рендеру: + +1. Для активных инстансов FX вычисляется runtime-коэффициент (`time_mode + flags`). +2. Команды FX обновляют внутреннее состояние. +3. Команды emit-этапа формируют примитивы/батчи эффектов. +4. Эффекты рисуются в 3D-кадре с собственным счётчиком батчей. + +## 8. Псевдокод кадра + +```c +void RenderFrame(Scene* scene, Camera* cam, float dt) { + UpdateGame(scene, dt); + + for (Object* obj : scene->objects) { + if (!obj->visible) continue; + + UpdateObjectAnimation(obj, scene->time); + BuildObjectNodeTransforms(obj); + } + + BeginFrame(cam); + + for (Object* obj : scene->objects) { + if (!obj->visible) continue; + RenderObjectMeshes(obj, cam); + } + + UpdateAndRenderFx(scene, dt, cam); + RenderUI(scene); + Present(); +} +``` + +## 9. Критичные условия для 1:1 + +1. Та же политика округления/FP для анимации и FX. +2. Та же логика fallback по материалам и текстурам. +3. Та же очередность стадий кадра. +4. Тот же контракт интерпретации `Res1/Res2/Res13/Res6`. +5. Тот же контракт `FXID` командного потока. + +## 10. Статус валидации + +- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущим runtime-кодом приложения и импортами движковых DLL. +- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`. diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md index 7021c82..329afc1 100644 --- a/docs/specs/runtime-pipeline.md +++ b/docs/specs/runtime-pipeline.md @@ -1,123 +1,8 @@ # Runtime pipeline -Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов. +Актуальный документ по полному кадру находится здесь: ---- +- [Render pipeline](render.md) -## 1.15. Алгоритм рендера модели (реконструкция) - -``` -Вход: model, instanceTransform, cameraFrustum - -1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам). - -2. Для каждого node (nodeIndex = 0 .. nodeCount−1): - a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform - - b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0] - если slotIndex == 0xFFFF → пропустить узел - - c. slot = slotTable[slotIndex] - - d. // Frustum culling: - transformedAABB = transform(slot.aabb, nodeTransform) - если transformedAABB вне cameraFrustum → пропустить - - // Альтернативно по сфере: - transformedCenter = nodeTransform × slot.sphereCenter - scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ) - если сфера вне frustum → пропустить - - e. Для i = 0 .. slot.batchCount − 1: - batch = batchTable[slot.batchStart + i] - - // Фильтрация по batchFlags (если нужна) - - // Установить материал: - setMaterial(batch.materialIndex) - - // Установить transform: - setWorldMatrix(nodeTransform) - - // Нарисовать: - DrawIndexedPrimitive( - baseVertex = batch.baseVertex, - indexStart = batch.indexStart, - indexCount = batch.indexCount, - primitiveType = TRIANGLE_LIST - ) -``` - ---- - -## 1.16. Алгоритм обхода треугольников (коллизия / пикинг) - -``` -Вход: model, nodeIndex, lod, group, filterMask, callback - -1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group] - если slotIndex == 0xFFFF → выход - -2. slot = slotTable[slotIndex] - triDescIndex = slot.triStart - -3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount − 1]: - batch = batchTable[batchIndex] - triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3 - - Для t = 0 .. triCount − 1: - triDesc = triDescTable[triDescIndex] - - // Фильтрация: - если (triDesc.triFlags & filterMask) → пропустить - - // Получить индексы вершин: - idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex - idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex - idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex - - // Получить позиции: - p0 = positions[idx0] - p1 = positions[idx1] - p2 = positions[idx2] - - callback(triDesc, idx0, idx1, idx2, p0, p1, p2) - - triDescIndex += 1 -``` - ---- - - ---- - -## 3.1. Архитектурный обзор - -Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`. - -### Экспорты Effect.dll - -| Функция | Описание | -|----------------------|--------------------------------------------------------| -| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) | -| `InitializeSettings` | Инициализировать настройки эффектов | - -`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами. - -### Телеметрия из Terrain.dll - -Terrain.dll содержит отладочную статистику рендера: - -``` -"Rendered meshes : %d" -"Rendered primitives : %d" -"Rendered faces : %d" -"Rendered particles/batches : %d/%d" -``` - -Из этого следует: - -- Частицы рендерятся **батчами** (группами). -- Статистика частиц отделена от статистики мешей. -- Частицы интегрированы в общий 3D‑рендер‑пайплайн. +Эта страница оставлена как совместимый указатель для старых ссылок. diff --git a/docs/specs/texture.md b/docs/specs/texture.md new file mode 100644 index 0000000..5fa1e9d --- /dev/null +++ b/docs/specs/texture.md @@ -0,0 +1,125 @@ +# Texture (`Texm`) + +`Texm` — основной формат текстур движка. + +Связанные страницы: + +- [Material (`MAT0`)](material.md) +- [Wear table (`WEAR`)](wear.md) +- [Render pipeline](render.md) + +## 1. Контейнер + +- Тип ресурса: `0x6D786554` (`Texm`). +- Используется в `Textures.lib`, `LightMap.lib` и других `NRes` архивах. + +## 2. Заголовок + +```c +struct TexmHeader32 { + uint32_t magic; // 'Texm' + uint32_t width; + uint32_t height; + uint32_t mipCount; + uint32_t flags4; + uint32_t flags5; + uint32_t unk6; + uint32_t format; +}; +``` + +## 3. Поддерживаемые форматы + +Базовые форматы: + +- `0` (8-bit indexed + palette) +- `565` +- `4444` +- `888` +- `8888` + +Дополнительные ветки загрузки поддерживают также `556` и `88`. + +## 4. Layout payload + +1. `TexmHeader32` (32 байта) +2. palette `1024` байта, если `format == 0` +3. mip-chain пикселей +4. optional `Page` chunk + +Расчёт ядра: + +```c +bytesPerPixel = + (format == 0) ? 1 : + (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 : + 4; + +pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1); +sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount; +``` + +## 5. `Page` chunk + +```c +struct PageChunk { + uint32_t magic; // 'Page' + uint32_t rectCount; + Rect16 rects[rectCount]; +}; + +struct Rect16 { + int16_t x; + int16_t w; + int16_t y; + int16_t h; +}; +``` + +`Page` задаёт atlas-прямоугольники для выборки под-областей текстуры. + +## 6. Mip-skip политика + +Загрузчик может пропускать первые mip-уровни в зависимости от: + +- `flags5`, +- размеров текстуры, +- количества mip. + +После `mipSkip`: + +- уменьшаются `width/height/mipCount`; +- сдвигается начало пиксельных данных; +- `Page`-координаты пересчитываются в соответствии с новым базовым уровнем. + +## 7. Палитры + +Для части текстур движок связывает палитру по суффиксу имени. + +Практический формат: + +- буква `A..Z` + вариант `""` или `0..9` +- всего `26 * 11 = 286` возможных слотов палитр. + +Невалидные суффиксы нужно считать ошибкой входных данных в инструментах. + +## 8. Кэширование + +Движок ведёт отдельные кэши: + +- общий texture cache; +- lightmap cache. + +Для обычных текстур используется отложенный сбор неиспользуемых слотов (по времени нулевого refcount). + +## 9. Правила writer/editor + +1. Не нормализовать `flags4/flags5/unk6`. +2. Сохранять payload без лишних хвостовых байт. +3. Если есть `Page`, его размер должен быть ровно `8 + rectCount * 8`. +4. Проверять `width > 0`, `height > 0`, `mipCount > 0`. + +## 10. Статус валидации + +- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`. +- В текущем окружении нет полного игрового набора текстур в `testdata`, поэтому массовая перепроверка не запускалась. diff --git a/docs/specs/wear.md b/docs/specs/wear.md new file mode 100644 index 0000000..61c799d --- /dev/null +++ b/docs/specs/wear.md @@ -0,0 +1,82 @@ +# Wear table (`WEAR`) + +`WEAR` — текстовый ресурс, который связывает слоты wear с именами материалов и lightmap. + +Связанные страницы: + +- [Material (`MAT0`)](material.md) +- [Texture (`Texm`)](texture.md) + +## 1. Контейнер + +- Тип ресурса: `0x52414557` (`WEAR`). +- Обычно хранится как `*.wea` внутри world/mission архивов. + +## 2. Формат текста + +```text + + +... (wearCount строк) + +[пустая строка] +[LIGHTMAPS + + +... (lightmapCount строк)] +``` + +`legacyId` читается, но логика выбора работает по имени. + +## 3. Совместимость парсинга + +В движке используются два режима чтения (`из файла` и `из буфера`), у которых различается обработка блока `LIGHTMAPS`. + +Практическое правило для полного совпадения: + +- если присутствует блок `LIGHTMAPS`, перед строкой `LIGHTMAPS` должна быть пустая строка-разделитель. + +## 4. Runtime-ограничения + +- Число wear-таблиц в менеджере ограничено: максимум `70`. +- Для `wearCount <= 0` ресурс считается некорректным. +- Для `LIGHTMAPS` блока `lightmapCount <= 0` — также ошибка формата. + +## 5. Поведение резолва + +### 5.1. Материал + +Для каждого wear-слота: + +1. Ищется материал по имени. +2. Если не найден — используется fallback (`DEFAULT`, затем индекс 0). + +### 5.2. Lightmap + +Для каждого lightmap-слота: + +1. Ищется текстура lightmap по имени. +2. Если не найдено — слот получает `-1`. + +## 6. Handle-кодирование + +Движок кодирует ссылку на material-slot как: + +```c +handle = (tableIndex << 16) | wearIndex +``` + +- `tableIndex` — номер wear-таблицы. +- `wearIndex` — индекс строки внутри таблицы. + +## 7. Правила writer/editor + +1. Сохранять порядок строк. +2. Не переставлять и не нормализовать `legacyId`. +3. Для совместимости buffer-парсинга сохранять пустую строку перед `LIGHTMAPS`. +4. Проверять, что число строк соответствует `wearCount`/`lightmapCount`. + +## 8. Статус валидации + +- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном. +- Массовый прогон по полному игровому набору в этом окружении не выполнялся из-за отсутствия корпуса данных в `testdata`. diff --git a/mkdocs.yml b/mkdocs.yml index 6c9724e..cf0907b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,13 +29,17 @@ nav: - Behavior system: specs/behavior.md - Control system: specs/control.md - FXID: specs/fxid.md - - Materials + Texm: specs/materials-texm.md + - Material (MAT0): specs/material.md + - Wear (WEAR): specs/wear.md + - Texture (Texm): specs/texture.md + - Materials index: specs/materials-texm.md - Missions: specs/missions.md - MSH animation: specs/msh-animation.md - MSH core: specs/msh-core.md - Network system: specs/network.md - NRes / RsLi: specs/nres.md - - Runtime pipeline: specs/runtime-pipeline.md + - Render pipeline: specs/render.md + - Runtime pointer: specs/runtime-pipeline.md - Sound system: specs/sound.md - Terrain + map loading: specs/terrain-map-loading.md - UI system: specs/ui.md